1088 lines
41 KiB
Vue
1088 lines
41 KiB
Vue
<script setup>
|
|
import { ref, onMounted, computed, watch, onUnmounted } from 'vue';
|
|
import { usePosStore } from '../stores/pos';
|
|
import { usePageTitle } from '../composables/Core/usePageTitle';
|
|
import { useAuth } from '../composables/Core/useAuth';
|
|
import { useUserStore } from '../stores/user';
|
|
import { useUserSettings } from '../composables/useUserSettings';
|
|
import { useUIStore } from '../stores/ui';
|
|
import { useModal } from '../composables/Core/useModal';
|
|
import { usePosSession } from '../composables/Market/usePosSession';
|
|
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
|
import FileImage from '../Components/Core/FileImage.vue';
|
|
import LottiePlayer from '../Components/Core/Animations/LottiePlayer.vue';
|
|
import { useNavigate } from '../composables/Core/useNavigate';
|
|
import { Html5QrcodeScanner } from 'html5-qrcode';
|
|
import { useOfflineStore } from '../composables/useOfflineStore';
|
|
import { useNetworkStore } from '../stores/network';
|
|
|
|
const props = defineProps({
|
|
target: { type: String, default: null }, // Session hashkey
|
|
access_key: { type: String, default: null }, // Guest access key
|
|
});
|
|
|
|
const posStore = usePosStore();
|
|
const uiStore = useUIStore();
|
|
const userStore = useUserStore();
|
|
const modal = useModal();
|
|
const { user, role } = useAuth();
|
|
const { settings, updateSetting } = useUserSettings();
|
|
const { navigate } = useNavigate();
|
|
const offlineStore = useOfflineStore();
|
|
const networkStore = useNetworkStore();
|
|
|
|
const {
|
|
storeHash,
|
|
showSuccessAnimation,
|
|
initialize,
|
|
completeTransaction,
|
|
startNewSessionSilently,
|
|
getStoredAccessKey,
|
|
isOfflineMode
|
|
} = usePosSession(props);
|
|
|
|
const { setTitle: setPageTitle } = usePageTitle();
|
|
|
|
const { pushPendingTransactions, isSyncing, syncErrors, pendingTransactionsCount, updatePendingCount } = offlineStore;
|
|
|
|
const showSyncDashboard = ref(false);
|
|
const syncResults = ref(null);
|
|
|
|
const handleSync = async () => {
|
|
syncResults.value = await pushPendingTransactions();
|
|
// After sync, if still on POS, update stats and history
|
|
if (networkStore.isOnline && storeHash.value) {
|
|
await posStore.fetchTodayStats(storeHash.value);
|
|
await posStore.fetchPosSessions(storeHash.value, 1);
|
|
|
|
// Reset offline mode indicator if all synced
|
|
if (pendingTransactionsCount.value === 0) {
|
|
isOfflineMode.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const searchQuery = ref('');
|
|
const selectedCategory = ref('All');
|
|
const showScanner = ref(false);
|
|
const scannerInstance = ref(null);
|
|
const showCustomerModal = ref(false);
|
|
const customerName = ref('');
|
|
const showInsufficientPaymentModal = ref(false);
|
|
|
|
// Quantity Modal State
|
|
const showQuantityModal = ref(false);
|
|
const selectedProduct = ref(null);
|
|
const batchQuantity = ref(1);
|
|
const overridePrice = ref(0);
|
|
const overrideSubtotal = ref(0);
|
|
const isOverridingPrice = ref(false);
|
|
|
|
const layoutMode = ref('landscape'); // Default
|
|
|
|
watch(() => settings.value, (newSettings) => {
|
|
if (newSettings && newSettings.pos_layout) {
|
|
layoutMode.value = newSettings.pos_layout;
|
|
}
|
|
}, { deep: true, immediate: true });
|
|
|
|
watch(layoutMode, (newMode) => {
|
|
uiStore.setFullWidth(newMode === 'landscape');
|
|
}, { immediate: true });
|
|
|
|
const toggleLayout = () => {
|
|
const newMode = layoutMode.value === 'landscape' ? 'regular' : 'landscape';
|
|
layoutMode.value = newMode;
|
|
updateSetting('pos_layout', newMode);
|
|
};
|
|
|
|
const storeName = computed(() => {
|
|
return posStore.activeSession?.store?.name || posStore.todayStats.store_name || '';
|
|
});
|
|
|
|
watch(() => posStore.activeSession, (session) => {
|
|
if (session?.store?.hashkey) {
|
|
storeHash.value = session.store.hashkey;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
watch(storeName, (name) => {
|
|
if (name) setPageTitle(`${name} | POS`);
|
|
}, { immediate: true });
|
|
|
|
watch(customerName, (val) => {
|
|
const hash = storeHash.value || posStore.activeSession?.store?.hashkey;
|
|
if (val && val.length >= 2) {
|
|
posStore.fetchCustomerSuggestions(val, hash);
|
|
} else if (!val) {
|
|
// Show last 3 most recent if empty
|
|
posStore.fetchCustomerSuggestions('', hash);
|
|
}
|
|
});
|
|
|
|
const handleCustomerFocus = () => {
|
|
if (!customerName.value) {
|
|
const hash = storeHash.value || posStore.activeSession?.store?.hashkey;
|
|
posStore.fetchCustomerSuggestions('', hash);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
const filteredProducts = computed(() => {
|
|
let prods = posStore.products;
|
|
if (selectedCategory.value !== 'All') {
|
|
prods = prods.filter(p => p.category === selectedCategory.value);
|
|
}
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase();
|
|
prods = prods.filter(p =>
|
|
p.name.toLowerCase().includes(query) ||
|
|
(p.barcode && p.barcode.includes(query))
|
|
);
|
|
}
|
|
return prods;
|
|
});
|
|
|
|
const startScanner = () => {
|
|
showScanner.value = true;
|
|
setTimeout(() => {
|
|
scannerInstance.value = new Html5QrcodeScanner("reader", {
|
|
fps: 10,
|
|
qrbox: { width: 200, height: 200 },
|
|
aspectRatio: 1.0
|
|
});
|
|
scannerInstance.value.render((decodedText) => {
|
|
handleScanSuccess(decodedText);
|
|
});
|
|
}, 100);
|
|
};
|
|
|
|
const stopScanner = () => {
|
|
if (scannerInstance.value) {
|
|
scannerInstance.value.clear();
|
|
scannerInstance.value = null;
|
|
}
|
|
showScanner.value = false;
|
|
};
|
|
|
|
const handleScanSuccess = async (decodedText) => {
|
|
if (!showScanner.value) return;
|
|
|
|
const product = posStore.products.find(p => p.barcode === decodedText || p.qrcode === decodedText || p.hashkey === decodedText);
|
|
if (product) {
|
|
if (!posStore.activeSession) {
|
|
await startNewSessionSilently();
|
|
}
|
|
await posStore.addToCart(product.hashkey);
|
|
// Optional: play a sound
|
|
} else {
|
|
modal.open({
|
|
title: 'Product Not Found',
|
|
body: `Product not found: ${decodedText}`
|
|
});
|
|
}
|
|
stopScanner();
|
|
};
|
|
|
|
const handleProductClick = (product) => {
|
|
if (!product) return;
|
|
|
|
selectedProduct.value = product;
|
|
|
|
selectedProduct.value = product;
|
|
|
|
// Check if already in cart and pre-fill quantity/price
|
|
const existingItem = posStore.cart?.find(item => item.product?.hashkey === product.hashkey);
|
|
batchQuantity.value = existingItem ? existingItem.quantity : 1;
|
|
overridePrice.value = existingItem ? existingItem.price_at_sale : product.price;
|
|
overrideSubtotal.value = overridePrice.value * batchQuantity.value;
|
|
isOverridingPrice.value = false;
|
|
|
|
showQuantityModal.value = true;
|
|
};
|
|
|
|
const incrementBatch = () => {
|
|
batchQuantity.value++;
|
|
updateSubtotal();
|
|
};
|
|
|
|
const decrementBatch = () => {
|
|
if (batchQuantity.value > 1) {
|
|
batchQuantity.value--;
|
|
updateSubtotal();
|
|
}
|
|
};
|
|
|
|
const updateSubtotal = () => {
|
|
overrideSubtotal.value = overridePrice.value * batchQuantity.value;
|
|
};
|
|
|
|
const updatePriceFromSubtotal = () => {
|
|
if (batchQuantity.value > 0) {
|
|
overridePrice.value = Math.round(overrideSubtotal.value / batchQuantity.value);
|
|
}
|
|
};
|
|
|
|
watch(batchQuantity, () => {
|
|
updateSubtotal();
|
|
});
|
|
|
|
const togglePriceOverride = () => {
|
|
isOverridingPrice.value = !isOverridingPrice.value;
|
|
};
|
|
|
|
const confirmBatchQuantity = async () => {
|
|
if (!selectedProduct.value) return;
|
|
|
|
if (!posStore.activeSession) {
|
|
await startNewSessionSilently();
|
|
}
|
|
|
|
if (posStore.activeSession) {
|
|
const customPrice = overridePrice.value !== selectedProduct.value.price ? overridePrice.value : null;
|
|
await posStore.addToCart(selectedProduct.value.hashkey, batchQuantity.value, customPrice);
|
|
showQuantityModal.value = false;
|
|
selectedProduct.value = null;
|
|
} else {
|
|
if (typeof toastr !== 'undefined') {
|
|
toastr.error('Session required. Please refresh or check access key.');
|
|
} else {
|
|
console.error('Session required to add to cart.');
|
|
}
|
|
}
|
|
};
|
|
|
|
const isAlreadyInCart = computed(() => {
|
|
if (!selectedProduct.value) return false;
|
|
return posStore.cart.some(item => item.product?.hashkey === selectedProduct.value.hashkey);
|
|
});
|
|
|
|
const handleCartItemQuantityClick = (item) => {
|
|
selectedProduct.value = item.product;
|
|
batchQuantity.value = item.quantity;
|
|
overridePrice.value = item.price_at_sale;
|
|
overrideSubtotal.value = item.total_price;
|
|
isOverridingPrice.value = false;
|
|
showQuantityModal.value = true;
|
|
};
|
|
|
|
const formatCurrency = (amount) => {
|
|
return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(amount);
|
|
};
|
|
|
|
const handleComplete = async () => {
|
|
if (posStore.receivedAmount < posStore.totalAmount) {
|
|
showInsufficientPaymentModal.value = true;
|
|
return;
|
|
}
|
|
showCustomerModal.value = true;
|
|
|
|
// Pre-fetch/show recent customers
|
|
const hash = storeHash.value || posStore.activeSession?.store?.hashkey;
|
|
posStore.fetchCustomerSuggestions('', hash);
|
|
};
|
|
|
|
const confirmComplete = async () => {
|
|
const success = await completeTransaction(customerName.value);
|
|
if (success) {
|
|
showCustomerModal.value = false;
|
|
customerName.value = '';
|
|
}
|
|
};
|
|
|
|
const goToHistory = () => {
|
|
navigate({
|
|
page: 'PosHistory',
|
|
props: { target: storeHash.value }
|
|
});
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await initialize();
|
|
await updatePendingCount();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopScanner();
|
|
uiStore.setFullWidth(false);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div :class="['pos-container', `layout-${layoutMode}`]">
|
|
<!-- Modals moved to top of container for better stacking control -->
|
|
<div v-if="showQuantityModal" class="scanner-modal" @click.self="showQuantityModal = false">
|
|
<div class="scanner-container shadow-lg rounded-xl overflow-hidden bg-white p-4 animate-bounce-in">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="fw_7 mb-0">Select Quantity</h5>
|
|
<button @click="showQuantityModal = false" class="btn-close"></button>
|
|
</div>
|
|
|
|
<div v-if="selectedProduct" class="selected-product-info d-flex align-items-center mb-4 p-3 rounded-xl bg-light">
|
|
<div class="product-thumb-small me-3">
|
|
<FileImage :src="selectedProduct.photo ? (Array.isArray(selectedProduct.photo) ? selectedProduct.photo[0] : selectedProduct.photo) : ''" />
|
|
</div>
|
|
<div>
|
|
<div class="fw_7">{{ selectedProduct.name }}</div>
|
|
<div class="text-primary fw_6">{{ formatCurrency(selectedProduct.price) }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="quantity-input-section mb-4">
|
|
<div class="row g-3">
|
|
<div class="col-12">
|
|
<label class="small text-muted mb-2 d-block fw_6 text-center">Quantity</label>
|
|
<div class="d-flex align-items-center justify-content-center gap-3">
|
|
<button @click="decrementBatch" class="btn btn-lg btn-light rounded-circle shadow-sm" style="width: 50px; height: 50px;">
|
|
<i class="fas fa-minus"></i>
|
|
</button>
|
|
<input v-model.number="batchQuantity" type="number" class="form-control form-control-lg text-center fw_8 border-0 bg-light rounded-xl" style="width: 100px; font-size: 1.5rem;" min="1" @keyup.enter="confirmBatchQuantity">
|
|
<button @click="incrementBatch" class="btn btn-lg btn-primary rounded-circle shadow-sm" style="width: 50px; height: 50px;">
|
|
<i class="fas fa-plus"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-6">
|
|
<label class="small text-muted mb-1 fw_6">Unit Price</label>
|
|
<div class="input-group dark-input-group shadow-sm">
|
|
<span class="input-group-text border-0 ps-3">₱</span>
|
|
<input v-model.number="overridePrice" type="number" class="form-control border-0 py-2 fw_7" @input="updateSubtotal">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-6">
|
|
<label class="small text-muted mb-1 fw_6">Subtotal</label>
|
|
<div class="input-group dark-input-group shadow-sm">
|
|
<span class="input-group-text border-0 ps-3">₱</span>
|
|
<input v-model.number="overrideSubtotal" type="number" class="form-control border-0 py-2 fw_7" @input="updatePriceFromSubtotal">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="total-display mb-4 p-3 rounded-xl border border-dashed text-center" v-if="overridePrice !== (selectedProduct?.price || 0)">
|
|
<div class="smallest text-muted text-uppercase fw_6 mb-1">Modified Subtotal</div>
|
|
<div class="h3 fw_8 mb-0 text-success">
|
|
{{ formatCurrency(overrideSubtotal) }}
|
|
</div>
|
|
<div class="smallest text-muted mt-1">
|
|
Original: {{ formatCurrency((selectedProduct?.price || 0) * batchQuantity) }}
|
|
</div>
|
|
</div>
|
|
<div v-else class="total-display mb-4 p-3 rounded-xl border border-dashed text-center">
|
|
<div class="smallest text-muted text-uppercase fw_6 mb-1">Subtotal</div>
|
|
<div class="h3 fw_8 mb-0 text-primary">
|
|
{{ formatCurrency((selectedProduct?.price || 0) * batchQuantity) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<button @click="showQuantityModal = false" class="btn btn-light flex-fill rounded-pill py-2 fw_6">Cancel</button>
|
|
<button @click="confirmBatchQuantity" class="btn btn-primary flex-fill rounded-pill py-2 fw_7 shadow-sm">
|
|
{{ isAlreadyInCart ? 'Change Quantity' : 'Confirm & Add' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showScanner" class="scanner-modal" @click.self="stopScanner">
|
|
<div class="scanner-container shadow-lg rounded-xl overflow-hidden bg-white p-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="fw_7 mb-0">Scan Product</h5>
|
|
<button @click="stopScanner" class="btn-close"></button>
|
|
</div>
|
|
<div id="reader" style="width: 100%"></div>
|
|
<div class="mt-3 text-center">
|
|
<button @click="stopScanner" class="btn btn-light rounded-pill px-4">Close Scanner</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showInsufficientPaymentModal" class="scanner-modal" @click.self="showInsufficientPaymentModal = false">
|
|
<div class="scanner-container shadow-lg rounded-xl overflow-hidden bg-white p-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="fw_7 mb-0">Insufficient Payment</h5>
|
|
<button @click="showInsufficientPaymentModal = false" class="btn-close"></button>
|
|
</div>
|
|
<div class="alert alert-warning d-flex align-items-center gap-2 rounded-xl mb-3">
|
|
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
|
|
<span class="small fw_6">Received payment is not enough to complete this transaction.</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted small">Total Amount</span>
|
|
<span class="fw_7 text-primary">{{ formatCurrency(posStore.totalAmount) }}</span>
|
|
</div>
|
|
<div class="form-group mb-4">
|
|
<label class="small text-muted mb-1 fw_6">Received Payment</label>
|
|
<div class="input-group dark-input-group shadow-sm">
|
|
<span class="input-group-text border-0 ps-3">₱</span>
|
|
<input v-model.number="posStore.receivedAmount" type="number" class="form-control border-0 py-2 fw_7" placeholder="0.00" autofocus>
|
|
</div>
|
|
<div v-if="posStore.receivedAmount > 0 && posStore.receivedAmount < posStore.totalAmount" class="text-danger smallest mt-1">
|
|
Still short by {{ formatCurrency(posStore.totalAmount - posStore.receivedAmount) }}
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button @click="showInsufficientPaymentModal = false" class="btn btn-light flex-fill rounded-pill py-2">Cancel</button>
|
|
<button @click="showInsufficientPaymentModal = false; handleComplete()" class="btn btn-primary flex-fill rounded-pill py-2 fw_7 shadow-sm" :disabled="posStore.receivedAmount < posStore.totalAmount">
|
|
Continue
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showCustomerModal" class="scanner-modal" @click.self="showCustomerModal = false">
|
|
<div class="scanner-container shadow-lg rounded-xl overflow-hidden bg-white p-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="fw_7 mb-0">Complete Transaction</h5>
|
|
<button @click="showCustomerModal = false" class="btn-close"></button>
|
|
</div>
|
|
<p class="text-muted small mb-4">Please enter the customer's name to complete this transaction.</p>
|
|
<div class="form-group mb-4 position-relative">
|
|
<label class="small text-muted mb-1 fw_6">Customer Name</label>
|
|
<input v-model="customerName" type="text" class="form-control rounded-pill px-3 py-2 bg-light border-0" placeholder="e.g. Juan Dela Cruz" autofocus @keyup.enter="confirmComplete" @focus="handleCustomerFocus" autocomplete="off">
|
|
<div v-if="customerName && posStore.customerSuggestions.length > 0" class="suggestions-dropdown shadow border rounded-xl overflow-hidden bg-white">
|
|
<div v-for="customer in posStore.customerSuggestions" :key="customer.id" class="suggestion-item p-2 px-3 border-bottom-dashed" @click="customerName = customer.name; posStore.customerSuggestions = []">
|
|
<div class="fw_6 small">{{ customer.name }}</div>
|
|
<div v-if="customer.phone" class="smallest text-muted">{{ customer.phone }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button @click="showCustomerModal = false" class="btn btn-light flex-fill rounded-pill py-2">Cancel</button>
|
|
<button @click="confirmComplete" class="btn btn-primary flex-fill rounded-pill py-2 fw_7 shadow-sm" :disabled="posStore.loading">
|
|
{{ posStore.loading ? 'Processing...' : 'Confirm & Pay' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div v-if="showSyncDashboard" class="scanner-modal" @click.self="showSyncDashboard = false">
|
|
<div class="scanner-container shadow-lg rounded-xl overflow-hidden bg-white p-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5 class="fw_7 mb-0">Offline Sync Dashboard</h5>
|
|
<button @click="showSyncDashboard = false" class="btn-close"></button>
|
|
</div>
|
|
|
|
<div class="sync-info mb-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span class="text-muted">Status:</span>
|
|
<span :class="['fw_7', networkStore.isOnline ? 'text-success' : 'text-danger']">
|
|
{{ networkStore.isOnline ? 'Online' : 'Offline' }}
|
|
</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span class="text-muted">Pending Transactions:</span>
|
|
<span class="fw_7" :class="pendingTransactionsCount > 0 ? 'text-warning' : 'text-muted'">
|
|
{{ pendingTransactionsCount }}
|
|
</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span class="text-muted">Last Sync:</span>
|
|
<span class="fw_6">{{ offlineStore.lastSyncTime.value || 'Never' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="syncResults !== null" class="alert border-0 rounded-xl mb-4" :class="syncResults > 0 ? 'alert-success' : 'alert-warning'">
|
|
<i :class="['fas', 'me-2', syncResults > 0 ? 'fa-check-circle' : 'fa-exclamation-triangle']"></i>
|
|
{{ syncResults > 0 ? `Synced ${syncResults} transactions!` : 'No transactions were synced.' }}
|
|
</div>
|
|
<div v-if="syncErrors && syncErrors.length > 0" class="alert alert-danger border-0 rounded-xl mb-4 small">
|
|
<div class="fw_7 mb-1"><i class="fas fa-times-circle me-1"></i>Sync errors:</div>
|
|
<ul class="mb-0 ps-3">
|
|
<li v-for="(err, i) in syncErrors" :key="i">{{ err }}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<button
|
|
@click="handleSync"
|
|
class="btn btn-primary w-100 py-3 rounded-xl fw_8 shadow-lg"
|
|
:disabled="isSyncing || !networkStore.isOnline"
|
|
>
|
|
<i :class="['fas', isSyncing ? 'fa-spinner fa-spin' : 'fa-sync-alt', 'me-2']"></i>
|
|
{{ isSyncing ? 'Syncing...' : 'Sync Pending Transactions' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showSuccessAnimation" class="scanner-modal" style="background: rgba(255,255,255,1); z-index: 100001;">
|
|
<div class="text-center animate-bounce-in">
|
|
<LottiePlayer path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json" :loop="false" width="250px" height="250px" />
|
|
<h2 class="fw_8 mt-4 text-primary headline-gradient">Success!</h2>
|
|
<p class="text-muted">Transaction recorded successfully.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar: Products -->
|
|
<div class="products-side">
|
|
<div class="px-3 pt-3">
|
|
<div class="search-box mb-3">
|
|
<i class="fas fa-search search-icon"></i>
|
|
<input v-model="searchQuery" type="text" placeholder="Search product or scan barcode..." class="form-control rounded-pill ps-5 py-2 shadow-sm border-0 bg-light">
|
|
</div>
|
|
<div class="category-pills mb-3">
|
|
<button @click="selectedCategory = 'All'" :class="['pill', { active: selectedCategory === 'All' }]">All</button>
|
|
<button v-for="cat in posStore.categories" :key="cat" @click="selectedCategory = cat" :class="['pill', { active: selectedCategory === cat }]">{{ cat }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="product-grid p-3">
|
|
<div v-for="product in filteredProducts" :key="product.hashkey" class="product-card shadow-sm" @click="handleProductClick(product)">
|
|
<div class="product-image">
|
|
<FileImage :src="product.photo ? (Array.isArray(product.photo) ? product.photo[0] : product.photo) : ''" />
|
|
</div>
|
|
<div class="product-info">
|
|
<div class="product-name">{{ product.name }}</div>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div class="product-price">{{ formatCurrency(product.price) }}</div>
|
|
<div class="small text-muted">{{ product.available || 0 }} in stock</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Panel: Cart & Payment -->
|
|
<div class="cart-side">
|
|
<div class="cart-header p-3 border-bottom">
|
|
<div v-if="storeName" class="d-flex align-items-center justify-content-center mb-2 border-bottom pb-2 cursor-pointer" @click="navigate({ page: 'ViewStoreMarket', props: { target: storeHash } })">
|
|
<div v-if="posStore.todayStats.store_photo" class="store-logo-small me-2">
|
|
<FileImage :src="Array.isArray(posStore.todayStats.store_photo) ? posStore.todayStats.store_photo[0] : posStore.todayStats.store_photo" />
|
|
</div>
|
|
<div class="store-name-display fw_8 text-primary hover-underline">{{ storeName }}</div>
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div class="flex-grow-1 overflow-hidden me-2">
|
|
<h5 class="mb-0 fw_7">Current Order</h5>
|
|
<div v-if="posStore.todayStats.count > 0"
|
|
class="smallest text-success mt-1 fw_6 cursor-pointer hover-underline"
|
|
@click="goToHistory">
|
|
Today: {{ posStore.todayStats.count }} txns | {{ formatCurrency(posStore.todayStats.total) }}
|
|
</div>
|
|
</div>
|
|
<div v-if="posStore.activeSession" class="text-muted small me-2 d-none d-md-block">{{ posStore.activeSession.hashkey.substring(0, 8) }}</div>
|
|
<button v-if="!posStore.activeSession" @click="startNewSessionSilently" class="btn btn-primary btn-sm rounded-pill px-3" :disabled="posStore.loading">Start New Session</button>
|
|
<button v-else @click="startScanner" class="btn btn-outline-primary btn-sm rounded-pill px-3">
|
|
<i class="fas fa-barcode me-1"></i> Scan
|
|
</button>
|
|
<button @click="showSyncDashboard = true" class="btn btn-sm rounded-circle ms-2 d-flex align-items-center justify-content-center position-relative" :class="networkStore.isOnline ? 'btn-soft-success' : 'btn-soft-danger'" style="width: 32px; height: 32px;">
|
|
<i class="fas fa-cloud-upload-alt"></i>
|
|
<span v-if="pendingTransactionsCount > 0" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" style="font-size: 0.6rem; transform: translate(-50%, -50%) !important;">
|
|
{{ pendingTransactionsCount > 9 ? '9+' : pendingTransactionsCount }}
|
|
</span>
|
|
<span v-else-if="isOfflineMode" class="position-absolute top-0 start-100 translate-middle p-1 bg-danger border border-light rounded-circle"></span>
|
|
</button>
|
|
<button @click="toggleLayout" class="btn btn-outline-secondary btn-sm rounded-circle ms-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; flex-shrink: 0;" :title="layoutMode === 'landscape' ? 'Switch to Regular' : 'Switch to Landscape'">
|
|
<i :class="['fas', layoutMode === 'landscape' ? 'fa-tablet-alt' : 'fa-desktop']"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!userStore.isLoggedIn && !posStore.activeSession && posStore.error" class="alert alert-info mx-3 mt-3 mb-0 py-3 text-center rounded-4" role="alert">
|
|
<i class="fas fa-terminal fa-2x mb-2 d-block opacity-50"></i>
|
|
<div class="fw_6 mb-1">No Terminal Access</div>
|
|
<div class="small text-muted">This terminal has no active session. Please use your POS access key or log in.</div>
|
|
</div>
|
|
<div v-else-if="posStore.error" class="alert alert-warning alert-dismissible mx-3 mt-3 mb-0 py-2 small rounded-pill" role="alert">
|
|
{{ posStore.error }}
|
|
<button type="button" class="btn-close btn-sm" @click="posStore.error = null" aria-label="Close"></button>
|
|
</div>
|
|
|
|
<div class="cart-items p-3">
|
|
<div v-if="posStore.cart.length === 0" class="empty-cart text-center py-5 opacity-50">
|
|
<i class="fas fa-shopping-basket fa-3x mb-3"></i>
|
|
<p>Order is empty</p>
|
|
</div>
|
|
<div v-for="item in posStore.cart" :key="item.id" class="cart-item mb-3 p-3 rounded-xl shadow-sm border bg-transparent">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div class="cart-item-thumb me-3" v-if="item.product?.photo">
|
|
<FileImage :src="Array.isArray(item.product.photo) ? item.product.photo[0] : item.product.photo" />
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<div class="fw_6">{{ item.product?.name || 'Unknown Product' }}</div>
|
|
<div class="text-muted small">{{ formatCurrency(item.price_at_sale) }}</div>
|
|
</div>
|
|
<div class="item-controls d-flex align-items-center gap-2">
|
|
<button @click="posStore.addToCart(item.product?.hashkey, item.quantity - 1)" class="btn btn-sm btn-light rounded-circle" :disabled="item.quantity <= 1">-</button>
|
|
<span class="fw_6 px-2 cursor-pointer hover-text-primary" @click="handleCartItemQuantityClick(item)">{{ item.quantity }}</span>
|
|
<button @click="handleCartItemQuantityClick(item)" class="btn btn-sm btn-light rounded-circle">+</button>
|
|
<button @click="posStore.removeFromCart(item.id)" class="btn btn-sm btn-soft-danger ms-2">
|
|
<i class="fas fa-trash-alt"></i>
|
|
</button>
|
|
</div>
|
|
<div class="text-end ms-3" style="min-width: 80px;">
|
|
<div class="fw_7">{{ formatCurrency(item.total_price) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cart-footer p-4 border-top bg-transparent">
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted">Subtotal</span>
|
|
<span class="fw_6">{{ formatCurrency(posStore.totalAmount) }}</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-3">
|
|
<span class="text-muted">Tax (0%)</span>
|
|
<span class="fw_6">₱0.00</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-4 border-top pt-3">
|
|
<h4 class="fw_7 mb-0">Total</h4>
|
|
<h4 class="fw_7 mb-0 text-primary">{{ formatCurrency(posStore.totalAmount) }}</h4>
|
|
</div>
|
|
|
|
<div class="payment-section mt-auto pt-3 border-top">
|
|
<div class="row g-2 mb-3">
|
|
<div class="col-12 col-md-6 mb-2 mb-md-0">
|
|
<label class="small text-muted mb-1 d-block fw_6">Received Payment</label>
|
|
<div class="input-group dark-input-group shadow-sm">
|
|
<span class="input-group-text border-0 ps-3">₱</span>
|
|
<input v-model.number="posStore.receivedAmount" type="number" class="form-control border-0 py-2 fw_7" placeholder="0.00">
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-6">
|
|
<label class="small text-muted mb-1 d-block fw_6">Change</label>
|
|
<div class="change-box d-flex align-items-center px-3 fw_7 text-success border shadow-sm">
|
|
{{ formatCurrency(posStore.changeAmount) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button @click="handleComplete" class="btn btn-primary w-100 py-3 rounded-xl fw_8 shadow-lg mt-2" :disabled="!posStore.activeSession || posStore.cart.length === 0">
|
|
Complete Transaction
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.pos-container {
|
|
display: flex;
|
|
height: calc(100vh - 140px); /* Adjust for header/footer */
|
|
gap: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
@media (max-width: 991px) {
|
|
.pos-container {
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
height: auto;
|
|
min-height: calc(100vh - 140px);
|
|
}
|
|
|
|
.pos-container.layout-landscape {
|
|
flex-direction: row !important;
|
|
height: calc(100vh - 140px) !important;
|
|
overflow: hidden !important;
|
|
}
|
|
}
|
|
|
|
/* Base Styles */
|
|
.products-side {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--bg-secondary);
|
|
border-right: 1px solid var(--border-color);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.cart-side {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
/* Landscape Mode (Optimized Tablet Split) */
|
|
.pos-container.layout-landscape {
|
|
display: flex !important;
|
|
flex-direction: row !important;
|
|
}
|
|
|
|
.pos-container.layout-landscape .products-side {
|
|
flex: 1.2;
|
|
display: flex;
|
|
}
|
|
|
|
.pos-container.layout-landscape .cart-side {
|
|
width: 450px;
|
|
min-width: 400px;
|
|
display: flex;
|
|
}
|
|
|
|
/* Regular Mode (Traditional / Rest of the app feel) */
|
|
.pos-container.layout-regular {
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
height: auto;
|
|
min-height: calc(100vh - 140px);
|
|
}
|
|
|
|
.pos-container.layout-regular .products-side {
|
|
flex: none;
|
|
overflow-y: visible;
|
|
border-right: none;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.pos-container.layout-regular .cart-side {
|
|
width: 100%;
|
|
}
|
|
|
|
.pos-container.layout-regular .product-grid {
|
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); /* Slightly larger cards in regular mode */
|
|
}
|
|
|
|
.search-box {
|
|
position: relative;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 20px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #999;
|
|
}
|
|
|
|
.category-pills {
|
|
display: flex;
|
|
gap: 10px;
|
|
overflow-x: auto;
|
|
padding-bottom: 5px;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.pill {
|
|
padding: 6px 18px;
|
|
border-radius: 20px;
|
|
border: none;
|
|
background: #eee;
|
|
color: #666;
|
|
font-size: 0.9rem;
|
|
white-space: nowrap;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.profile-card {
|
|
background: var(--bg-card);
|
|
border-radius: 20px;
|
|
padding: 20px;
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.product-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
gap: 15px;
|
|
}
|
|
|
|
.product-card {
|
|
background: var(--bg-card);
|
|
border-radius: 15px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.product-card:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 8px 15px rgba(0,0,0,0.1) !important;
|
|
}
|
|
|
|
.product-image {
|
|
height: 120px;
|
|
background: #eee;
|
|
}
|
|
|
|
.product-image :deep(img) {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.product-info {
|
|
padding: 10px;
|
|
}
|
|
|
|
.product-name {
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
height: 2.4rem;
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
|
|
.product-price {
|
|
color: #42b983;
|
|
font-weight: 700;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.cart-items {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.rounded-xl {
|
|
border-radius: 15px;
|
|
}
|
|
|
|
.btn-soft-danger {
|
|
background: #fff0f0;
|
|
color: #e74c3c;
|
|
border: none;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.btn-soft-danger:hover {
|
|
background: #ffebeb;
|
|
color: #c0392b;
|
|
}
|
|
|
|
/* Dark Mode Overrides for Interactive Elements */
|
|
:global(.dark-mode) .btn-light {
|
|
background: #2d3138 !important; /* Deep charcoal */
|
|
color: #e0e0e0 !important;
|
|
border: 1px solid #444 !important;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
:global(.dark-mode) .btn-light:hover {
|
|
background: #3a3f48 !important;
|
|
border-color: #555 !important;
|
|
}
|
|
|
|
:global(.dark-mode) .btn-light:active {
|
|
background: #444a54 !important;
|
|
}
|
|
|
|
:global(.dark-mode) .btn-light:disabled {
|
|
background: #1a1c20 !important;
|
|
color: #555 !important;
|
|
border-color: #222 !important;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
:global(.dark-mode) .btn-soft-danger {
|
|
background: rgba(231, 76, 60, 0.1) !important; /* Semi-transparent red */
|
|
color: #ff6b6b !important;
|
|
border: 1px solid rgba(231, 76, 60, 0.1) !important;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
:global(.dark-mode) .btn-soft-danger:hover {
|
|
background: rgba(231, 76, 60, 0.2) !important;
|
|
color: #ff8585 !important;
|
|
border-color: rgba(231, 76, 60, 0.2) !important;
|
|
}
|
|
|
|
:global(.dark-mode) .cart-item {
|
|
border-color: rgba(255, 255, 255, 0.05) !important;
|
|
background: rgba(var(--bg-card-rgb), 0.5) !important;
|
|
backdrop-filter: blur(5px);
|
|
}
|
|
|
|
.scanner-modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
z-index: 200000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.store-actions .icon-wrapper {
|
|
width: 44px;
|
|
height: 44px;
|
|
background: var(--accent-soft);
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.scanner-container {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
}
|
|
|
|
:global(.dark-mode) .cart-side,
|
|
:global(.dark-mode) .cart-footer {
|
|
background: #1a1c20;
|
|
}
|
|
|
|
:global(.dark-mode) .products-side {
|
|
background: #24272c;
|
|
}
|
|
|
|
:global(.dark-mode) .product-card,
|
|
:global(.dark-mode) .cart-item {
|
|
background: #1a1c20;
|
|
border-color: #333 !important;
|
|
}
|
|
|
|
:global(.dark-mode) .pill {
|
|
background: #333;
|
|
color: #ccc;
|
|
}
|
|
|
|
:global(.dark-mode) .form-control {
|
|
background: #333 !important;
|
|
color: white;
|
|
}
|
|
|
|
.store-logo-small {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
background: #eee;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.store-logo-small :deep(img) {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.store-name-display {
|
|
font-size: 1.1rem;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.suggestions-dropdown {
|
|
position: relative;
|
|
margin-top: 5px;
|
|
z-index: 1000;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.suggestion-item {
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.suggestion-item:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.border-bottom-dashed {
|
|
border-bottom: 1px dashed #eee;
|
|
}
|
|
|
|
.border-bottom-dashed:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
:global(.dark-mode) .suggestions-dropdown {
|
|
background: #1a1c20;
|
|
border-color: #333;
|
|
}
|
|
|
|
:global(.dark-mode) .suggestion-item:hover {
|
|
background: #24272c;
|
|
}
|
|
|
|
:global(.dark-mode) .border-bottom-dashed {
|
|
border-color: #333;
|
|
}
|
|
|
|
.dark-input-group {
|
|
background: var(--bg-secondary) !important;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.dark-input-group .input-group-text {
|
|
background: transparent !important;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.dark-input-group input {
|
|
background: transparent !important;
|
|
}
|
|
|
|
.change-box {
|
|
background: var(--bg-secondary) !important;
|
|
border-radius: 12px;
|
|
height: 44px;
|
|
border-color: var(--border-color) !important;
|
|
}
|
|
|
|
.btn-lg {
|
|
font-size: 1.1rem;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.cursor-pointer { cursor: pointer; }
|
|
.hover-underline:hover { text-decoration: underline; }
|
|
.hover-text-primary:hover { color: var(--bs-primary); }
|
|
|
|
.product-thumb-small {
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
background: #eee;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.product-thumb-small :deep(img) {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.cart-item-thumb {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
background: var(--bg-secondary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.cart-item-thumb :deep(img) {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.form-control-lg {
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
:global(.dark-mode) .selected-product-info {
|
|
background: #24272c !important;
|
|
}
|
|
|
|
:global(.dark-mode) .total-display {
|
|
border-color: #444 !important;
|
|
}
|
|
</style>
|