From 5796e4d8683cba97b6f5b6bfe274fec5cebdf997 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 19:35:56 +0700 Subject: [PATCH] feat(pos): implement integrated full-screen POS point-of-sale system with inventory deduction and thermal receipt --- apps/admin/src/App.tsx | 5 + apps/admin/src/components/AdminLayout.tsx | 16 +- apps/admin/src/pages/OrderDetailPage.tsx | 55 +- apps/admin/src/pages/OrdersPage.tsx | 21 +- apps/admin/src/pages/PosPage.tsx | 1353 +++++++++++++++++++++ apps/backend/prisma/schema.prisma | 20 +- apps/backend/src/routes/customers.ts | 30 + apps/backend/src/routes/orders.ts | 61 +- apps/backend/src/routes/products.ts | 5 + packages/types/index.ts | 6 + 10 files changed, 1549 insertions(+), 23 deletions(-) create mode 100644 apps/admin/src/pages/PosPage.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 0c6969b..91ccdf2 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -68,6 +68,7 @@ import RepeatCustomerDetailPage from './pages/analytics/RepeatCustomerDetailPage import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage' import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage' import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage' +import PosPage from './pages/PosPage' function App() { return ( @@ -78,8 +79,12 @@ function App() { {/* Guarded Admin Routes */} }> + {/* Fullscreen POS Route (no admin sidebar) */} + } /> + }> } /> + } /> } /> } /> diff --git a/apps/admin/src/components/AdminLayout.tsx b/apps/admin/src/components/AdminLayout.tsx index 12c926a..3e273b7 100644 --- a/apps/admin/src/components/AdminLayout.tsx +++ b/apps/admin/src/components/AdminLayout.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import { Outlet } from 'react-router-dom' +import { Outlet, Link } from 'react-router-dom' +import { Store } from 'lucide-react' import Sidebar from './Sidebar' import LanguageSelector from './LanguageSelector' @@ -19,11 +20,22 @@ export default function AdminLayout() { -
+
+ + + ระบบ POS + Live + +
+ {/* Main Content Area */}
diff --git a/apps/admin/src/pages/OrderDetailPage.tsx b/apps/admin/src/pages/OrderDetailPage.tsx index 3ba092b..f7e99c6 100644 --- a/apps/admin/src/pages/OrderDetailPage.tsx +++ b/apps/admin/src/pages/OrderDetailPage.tsx @@ -48,6 +48,7 @@ export default function OrderDetailPage() { switch (status) { case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment') case 'PAID': return t('orders.statusPaid', 'Paid') + case 'COMPLETED': return t('orders.statusCompleted', 'Completed') case 'SHIPPED': return t('orders.statusShipped', 'Shipped') case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled') default: return status.replace('_', ' ') @@ -70,10 +71,22 @@ export default function OrderDetailPage() {
-

Order #{order.id.toString().padStart(4, '0')}

+
+

Order #{order.id.toString().padStart(4, '0')}

+ {order.channel === 'POS' ? ( + + POS In-Store + + ) : ( + + ONLINE Store + + )} +

{new Date(order.createdAt).toLocaleString(i18n.language === 'th' ? 'th-TH' : i18n.language === 'ja' ? 'ja-JP' : i18n.language === 'zh' ? 'zh-CN' : 'en-US')}

+
{/* Left Col: Order Info & Items */}
@@ -166,7 +180,36 @@ export default function OrderDetailPage() { {t('orders.paymentSlip', 'Payment Verification')} - {order.paymentSlipUrl ? ( + {order.channel === 'POS' ? ( +
+
+ ช่องทางการขาย + จุดขายหน้าร้าน (POS) +
+
+ วิธีการชำระ + {order.paymentMethod || 'เงินสด (CASH)'} +
+ {order.receivedAmount != null && ( +
+ ยอดเงินที่รับ + {formatPrice(order.receivedAmount)} +
+ )} + {order.changeAmount != null && ( +
+ เงินทอน + {formatPrice(order.changeAmount)} +
+ )} + {order.pointsEarned ? ( +
+ แต้มสะสมที่มอบให้ + +{order.pointsEarned} แต้ม +
+ ) : null} +
+ ) : order.paymentSlipUrl ? ( +
diff --git a/apps/admin/src/pages/OrdersPage.tsx b/apps/admin/src/pages/OrdersPage.tsx index 7719aff..e2a8004 100644 --- a/apps/admin/src/pages/OrdersPage.tsx +++ b/apps/admin/src/pages/OrdersPage.tsx @@ -44,6 +44,7 @@ export default function OrdersPage() { switch (status) { case 'PENDING_PAYMENT': return case 'PAID': return + case 'COMPLETED': return case 'SHIPPED': return case 'CANCELLED': return default: return null @@ -54,6 +55,7 @@ export default function OrdersPage() { switch (status) { case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment') case 'PAID': return t('orders.statusPaid', 'Paid') + case 'COMPLETED': return t('orders.statusCompleted', 'Completed') case 'SHIPPED': return t('orders.statusShipped', 'Shipped') case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled') default: return status.replace('_', ' ') @@ -64,10 +66,12 @@ export default function OrdersPage() { { key: 'ALL', label: t('orders.statusAll', 'All') }, { key: 'PENDING_PAYMENT', label: t('orders.statusPending', 'Pending Payment') }, { key: 'PAID', label: t('orders.statusPaid', 'Paid') }, + { key: 'COMPLETED', label: t('orders.statusCompleted', 'Completed') }, { key: 'SHIPPED', label: t('orders.statusShipped', 'Shipped') }, { key: 'CANCELLED', label: t('orders.statusCancelled', 'Cancelled') }, ] + return (
{/* Header */} @@ -141,8 +145,21 @@ export default function OrdersPage() { ) : ( filteredOrders.map((order) => ( - - #{order.id.toString().padStart(4, '0')} + +
+ + #{order.id.toString().padStart(4, '0')} + + {order.channel === 'POS' ? ( + + POS + + ) : ( + + ONLINE + + )} +
{order.customer ? ( diff --git a/apps/admin/src/pages/PosPage.tsx b/apps/admin/src/pages/PosPage.tsx new file mode 100644 index 0000000..c002df2 --- /dev/null +++ b/apps/admin/src/pages/PosPage.tsx @@ -0,0 +1,1353 @@ +import { useState, useEffect, useRef, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Store, + Search, + ShoppingCart, + Trash2, + Plus, + Minus, + Maximize2, + Minimize2, + ArrowLeft, + User, + UserPlus, + CreditCard, + Banknote, + QrCode, + Building2, + CheckCircle2, + Printer, + RotateCcw, + Sparkles, + Package, + X, + AlertCircle +} from 'lucide-react' +import { API_BASE, formatPrice, type Product, type Category, type Order } from '@homaroma/types' +import { getAdminDisplayString } from '../utils/localization' + +interface PosCartItem { + productId: number + productName: string + productImage?: string | null + variantId?: number | null + variantName?: string | null + sku?: string | null + barcode?: string | null + price: number + quantity: number + maxStock: number +} + +interface PosCustomer { + id: number + displayName?: string | null + phone?: string | null + email?: string | null + points: number +} + +export default function PosPage() { + const navigate = useNavigate() + + // Cashier User + const [cashier, setCashier] = useState<{ id?: number; username?: string; name?: string } | null>(null) + + // Catalog State + const [products, setProducts] = useState([]) + const [categories, setCategories] = useState([]) + const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [loadingProducts, setLoadingProducts] = useState(true) + + // Cart State + const [cart, setCart] = useState([]) + + // Customer / Loyalty State + const [selectedCustomer, setSelectedCustomer] = useState(null) + const [customerModalOpen, setCustomerModalOpen] = useState(false) + const [customerSearch, setCustomerSearch] = useState('') + const [customerResults, setCustomerResults] = useState([]) + const [searchingCustomer, setSearchingCustomer] = useState(false) + const [isRegisteringCustomer, setIsRegisteringCustomer] = useState(false) + const [newCustomerName, setNewCustomerName] = useState('') + const [newCustomerPhone, setNewCustomerPhone] = useState('') + const [usePoints, setUsePoints] = useState(false) + + // Variant Modal + const [variantModalProduct, setVariantModalProduct] = useState(null) + + // Payment Modal State + const [isPaymentOpen, setIsPaymentOpen] = useState(false) + const [paymentMethod, setPaymentMethod] = useState<'CASH' | 'PROMPTPAY' | 'CREDIT_CARD' | 'TRANSFER'>('CASH') + const [cashTendered, setCashTendered] = useState('') + const [submittingOrder, setSubmittingOrder] = useState(false) + + // Receipt Modal State + const [completedOrder, setCompletedOrder] = useState(null) + const [receiptModalOpen, setReceiptModalOpen] = useState(false) + + // Store Brand Settings + const [storeName, setStoreName] = useState('HomAroma Store') + const [pointsEarnRate, setPointsEarnRate] = useState(1.0) + + // Fullscreen State + const [isFullscreen, setIsFullscreen] = useState(false) + + // Search input ref for barcode scanning / auto focus + const searchInputRef = useRef(null) + + // Load Initial Data + useEffect(() => { + // Current cashier + try { + const userStr = localStorage.getItem('homaroma_admin_user') + if (userStr) setCashier(JSON.parse(userStr)) + } catch (e) { + console.error(e) + } + + // Load store settings + fetch(`${API_BASE}/settings`) + .then(res => res.json()) + .then(data => { + if (data.brandName) setStoreName(data.brandName) + if (data.pointsEarnRate) setPointsEarnRate(Number(data.pointsEarnRate)) + }) + .catch(console.error) + + // Load Categories & Products + Promise.all([ + fetch(`${API_BASE}/categories`).then(r => r.json()), + fetch(`${API_BASE}/products?limit=500&includeDeleted=false`).then(r => r.json()) + ]) + .then(([catData, prodData]) => { + setCategories(Array.isArray(catData) ? catData : []) + setProducts(prodData?.data || (Array.isArray(prodData) ? prodData : [])) + }) + .catch(err => console.error('Error fetching POS catalog:', err)) + .finally(() => setLoadingProducts(false)) + }, []) + + // Auto-focus barcode/search on load and modal close + useEffect(() => { + if (!isPaymentOpen && !customerModalOpen && !variantModalProduct && !receiptModalOpen) { + searchInputRef.current?.focus() + } + }, [isPaymentOpen, customerModalOpen, variantModalProduct, receiptModalOpen]) + + // Fullscreen Handler + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(console.error) + setIsFullscreen(true) + } else { + document.exitFullscreen().catch(console.error) + setIsFullscreen(false) + } + } + + // Filtered Products + const filteredProducts = useMemo(() => { + return products.filter(p => { + const matchCategory = selectedCategoryId === null || p.categoryId === selectedCategoryId + if (!matchCategory) return false + + if (!searchQuery.trim()) return true + const q = searchQuery.toLowerCase().trim() + const name = getAdminDisplayString(p.name).toLowerCase() + const brand = p.brand?.name?.toLowerCase() || '' + const skuMatch = p.variants?.some(v => v.sku?.toLowerCase().includes(q) || v.barcode?.toLowerCase().includes(q)) + + return name.includes(q) || brand.includes(q) || skuMatch + }) + }, [products, selectedCategoryId, searchQuery]) + + // Handle Search Input Keydown (Barcode Scanner fast enter) + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + const query = searchQuery.trim().toLowerCase() + if (!query) return + + // Find exact barcode or SKU match first + for (const p of products) { + for (const v of p.variants || []) { + if (v.barcode?.toLowerCase() === query || v.sku?.toLowerCase() === query) { + addItemToCart(p, v) + setSearchQuery('') + return + } + } + } + + // If only 1 product matches search query + if (filteredProducts.length === 1) { + handleProductCardClick(filteredProducts[0]) + setSearchQuery('') + } + } + } + + // Add Item to Cart + const addItemToCart = (product: Product, variant?: any) => { + const pName = getAdminDisplayString(product.name) + const vName = variant + ? [variant.option1, variant.option2, variant.option3].filter(Boolean).join(' / ') + : null + const price = variant?.price ?? product.price ?? 0 + const maxStock = variant?.stock ?? 999 + const image = product.images?.[0]?.url + + setCart(prev => { + const existingIdx = prev.findIndex(item => + item.productId === product.id && item.variantId === (variant?.id || null) + ) + + if (existingIdx > -1) { + const updated = [...prev] + updated[existingIdx] = { + ...updated[existingIdx], + quantity: updated[existingIdx].quantity + 1 + } + return updated + } + + return [ + ...prev, + { + productId: product.id, + productName: pName, + productImage: image, + variantId: variant?.id || null, + variantName: vName, + sku: variant?.sku || null, + barcode: variant?.barcode || null, + price, + quantity: 1, + maxStock + } + ] + }) + } + + // Click on Product Card + const handleProductCardClick = (product: Product) => { + if (product.variants && product.variants.length > 1) { + setVariantModalProduct(product) + } else { + const singleVariant = product.variants?.[0] + addItemToCart(product, singleVariant) + } + } + + // Update Cart Quantity + const updateQuantity = (index: number, delta: number) => { + setCart(prev => { + const updated = [...prev] + const newQty = updated[index].quantity + delta + if (newQty <= 0) { + return updated.filter((_, i) => i !== index) + } + updated[index] = { ...updated[index], quantity: newQty } + return updated + }) + } + + // Remove Item from Cart + const removeFromCart = (index: number) => { + setCart(prev => prev.filter((_, i) => i !== index)) + } + + // Calculations + const subtotal = useMemo(() => { + return cart.reduce((sum, item) => sum + item.price * item.quantity, 0) + }, [cart]) + + // Points redemption: 1 Point = 1 THB discount + const availableCustomerPoints = selectedCustomer?.points || 0 + const pointsToUse = usePoints ? Math.min(availableCustomerPoints, Math.floor(subtotal)) : 0 + const pointsDiscount = pointsToUse // 1 pt = 1 THB + const grandTotal = Math.max(0, subtotal - pointsDiscount) + const pointsEarned = Math.floor(grandTotal * pointsEarnEarnRateSafe(pointsEarnRate)) + + function pointsEarnEarnRateSafe(rate: number) { + return isNaN(rate) || rate <= 0 ? 1 : rate + } + + // Open Checkout + const handleOpenCheckout = () => { + if (cart.length === 0) return + setCashTendered(String(grandTotal)) + setIsPaymentOpen(true) + } + + // Search Customer + const searchCustomers = async (keyword: string) => { + if (!keyword.trim()) { + setCustomerResults([]) + return + } + setSearchingCustomer(true) + try { + const res = await fetch(`${API_BASE}/customers?search=${encodeURIComponent(keyword)}`) + const data = await res.json() + setCustomerResults(Array.isArray(data) ? data : []) + } catch (err) { + console.error(err) + } finally { + setSearchingCustomer(false) + } + } + + // Quick Register New Customer + const handleRegisterCustomer = async () => { + if (!newCustomerPhone && !newCustomerName) return + try { + const res = await fetch(`${API_BASE}/customers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + displayName: newCustomerName || `ลูกค้า ${newCustomerPhone}`, + phone: newCustomerPhone + }) + }) + if (res.ok) { + const created = await res.json() + setSelectedCustomer(created) + setCustomerModalOpen(false) + setIsRegisteringCustomer(false) + setNewCustomerName('') + setNewCustomerPhone('') + } else { + alert('เกิดข้อผิดพลาดในการลงทะเบียนสมาชิก') + } + } catch (err) { + console.error(err) + alert('เกิดข้อผิดพลาด') + } + } + + // Confirm Sale & Create Order + const handleConfirmOrder = async () => { + if (cart.length === 0 || submittingOrder) return + + const cashValue = Number(cashTendered) || 0 + if (paymentMethod === 'CASH' && cashValue < grandTotal) { + alert(`ยอดเงินสดที่รับมา (฿${cashValue.toLocaleString()}) น้อยกว่ายอดรวม (฿${grandTotal.toLocaleString()})`) + return + } + + setSubmittingOrder(true) + try { + const orderPayload = { + channel: 'POS', + paymentMethod, + receivedAmount: paymentMethod === 'CASH' ? cashValue : grandTotal, + changeAmount: paymentMethod === 'CASH' ? Math.max(0, cashValue - grandTotal) : 0, + cashierId: cashier?.id || null, + customerId: selectedCustomer?.id || null, + customerName: selectedCustomer?.displayName || 'ลูกค้าหน้าร้าน (Walk-in)', + customerPhone: selectedCustomer?.phone || null, + shippingAddress: 'หน้าร้าน (Takeaway)', + subtotal, + pointsUsed: pointsToUse, + pointsDiscount, + totalAmount: grandTotal, + shippingFee: 0, + items: cart.map(item => ({ + productId: item.productId, + variantId: item.variantId, + quantity: item.quantity, + priceAtPurchase: item.price, + productName: item.productName, + variantName: item.variantName || undefined, + productImage: item.productImage || undefined + })) + } + + const res = await fetch(`${API_BASE}/orders`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(orderPayload) + }) + + if (!res.ok) { + const errData = await res.json() + throw new Error(errData.error || 'Failed to create order') + } + + const createdOrder: Order = await res.json() + + // Reset cart and customer + setCompletedOrder(createdOrder) + setIsPaymentOpen(false) + setReceiptModalOpen(true) + setCart([]) + setSelectedCustomer(null) + setUsePoints(false) + + // Optionally refresh product inventory + fetch(`${API_BASE}/products?limit=500&includeDeleted=false`) + .then(r => r.json()) + .then(prodData => setProducts(prodData?.data || [])) + .catch(console.error) + } catch (err: any) { + console.error('POS Checkout Error:', err) + alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกบิลขาย') + } finally { + setSubmittingOrder(false) + } + } + + // Print Receipt + const handlePrintReceipt = () => { + window.print() + } + + // Cash Quick Buttons + const cashGivenNumber = Number(cashTendered) || 0 + const changeDue = Math.max(0, cashGivenNumber - grandTotal) + + return ( +
+ {/* Top Header Bar */} +
+
+ + +
+ +
+
+ +
+
+
+ {storeName} + + POS Live + +
+ จุดขายหน้าร้าน +
+
+
+ + {/* Center: Search & Barcode Input */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="สแกนบาร์โค้ด หรือพิมพ์ชื่อสินค้า / SKU [Enter เพื่อเพิ่มทันที]..." + className="w-full bg-night-950/80 border border-white/10 focus:border-emerald-500/60 rounded-xl pl-10 pr-10 py-2 text-xs text-white placeholder-slate-500 outline-none transition-all shadow-inner" + /> + {searchQuery && ( + + )} +
+
+ + {/* Right Action Icons */} +
+ {/* Cashier Badge */} +
+
+ แคชเชียร์: + {cashier?.name || cashier?.username || 'ผู้ดูแลระบบ'} +
+ + {/* Fullscreen Button */} + +
+
+ + {/* Main Work Area: Left Catalog (65%) + Right Ticket/Cart (35%) */} +
+ {/* Left Catalog Area */} +
+ {/* Category Filter Pills */} +
+ + {categories.map(cat => { + const catName = getAdminDisplayString(cat.name) + const count = products.filter(p => p.categoryId === cat.id).length + return ( + + ) + })} +
+ + {/* Product Grid Area */} +
+ {loadingProducts ? ( +
+
+ กำลังโหลดรายการสินค้า... +
+ ) : filteredProducts.length === 0 ? ( +
+ + ไม่พบสินค้าตรงกับคำค้นหา + ลองเปลี่ยนหมวดหมู่หรือคำค้นหา +
+ ) : ( +
+ {filteredProducts.map(product => { + const pName = getAdminDisplayString(product.name) + const hasMultipleVariants = product.variants && product.variants.length > 1 + const totalStock = product.variants?.reduce((sum, v) => sum + (v.stock || 0), 0) ?? 0 + const isOutOfStock = totalStock <= 0 && (product.variants?.length || 0) > 0 + + return ( + + ) + })} +
+ )} +
+
+ + {/* Right Ticket / Cart Panel (35% width, min 380px) */} +
+ {/* Member / Customer Bar */} +
+ {selectedCustomer ? ( +
+
+
+ +
+
+
{selectedCustomer.displayName}
+
+ {selectedCustomer.phone || 'ไม่มีเบอร์'} + • {selectedCustomer.points.toLocaleString()} แต้ม +
+
+
+ +
+ ) : ( + + )} + + {/* Loyalty points discount toggle */} + {selectedCustomer && selectedCustomer.points > 0 && ( +
+ + + ใช้แต้มสะสม ({selectedCustomer.points} แต้ม) + + +
+ )} +
+ + {/* Cart Items List */} +
+ {cart.length === 0 ? ( +
+
+ +
+
+ ไม่มีสินค้าในตะกร้า + + คลิกเลือกสินค้า หรือยิงบาร์โค้ดเพื่อเริ่มการขาย + +
+
+ ) : ( + cart.map((item, index) => ( +
+ {/* Left: Thumbnail & Name */} +
+ {item.productImage ? ( + {item.productName} + ) : ( +
+ +
+ )} +
+
{item.productName}
+ {item.variantName && ( +
{item.variantName}
+ )} +
{formatPrice(item.price)}
+
+
+ + {/* Right: Quantity Adjusters & Total */} +
+
+ + {item.quantity} + +
+ +
+
+ {formatPrice(item.price * item.quantity)} +
+
+ + +
+
+ )) + )} +
+ + {/* Cart Summary & Actions */} +
+ {/* Calculation rows */} +
+
+ ยอดรวมสินค้า ({cart.reduce((s, i) => s + i.quantity, 0)} ชิ้น) + {formatPrice(subtotal)} +
+ {pointsDiscount > 0 && ( +
+ ส่วนลดแต้มสะสม ({pointsToUse} แต้ม) + -{formatPrice(pointsDiscount)} +
+ )} + {selectedCustomer && ( +
+ แต้มที่จะได้รับเพิ่ม + +{pointsEarned.toLocaleString()} แต้ม +
+ )} +
+ + {/* Grand Total */} +
+
+ ยอดสุทธิ (Total) + + {formatPrice(grandTotal)} + +
+ + {cart.length > 0 && ( + + )} +
+ + {/* Large Checkout Button */} + +
+
+
+ + {/* MODAL 1: Variant Selector Modal */} + {variantModalProduct && ( +
+
+
+
+

เลือกตัวเลือกสินค้า

+

{getAdminDisplayString(variantModalProduct.name)}

+
+ +
+ +
+ {variantModalProduct.variants?.map(v => { + const optName = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') + const isOut = (v.stock || 0) <= 0 + + return ( + + ) + })} +
+
+
+ )} + + {/* MODAL 2: Member Search & Register Modal */} + {customerModalOpen && ( +
+
+
+

+ {isRegisteringCustomer ? 'ลงทะเบียนสมาชิกร้าน' : 'ค้นหาข้อมูลสมาชิก'} +

+ +
+ + {isRegisteringCustomer ? ( +
+
+ + setNewCustomerName(e.target.value)} + placeholder="เช่น คุณกิตติศักดิ์" + className="w-full bg-night-950 border border-white/10 rounded-xl px-3 py-2 text-xs text-white outline-none focus:border-emerald-500" + /> +
+
+ + setNewCustomerPhone(e.target.value)} + placeholder="08X-XXX-XXXX" + className="w-full bg-night-950 border border-white/10 rounded-xl px-3 py-2 text-xs text-white outline-none focus:border-emerald-500" + /> +
+ +
+ + +
+
+ ) : ( +
+
+ + { + setCustomerSearch(e.target.value) + searchCustomers(e.target.value) + }} + placeholder="พิมพ์เบอร์โทร, ชื่อ หรืออีเมล..." + className="w-full bg-night-950 border border-white/10 rounded-xl pl-9 pr-3 py-2 text-xs text-white outline-none focus:border-emerald-500" + autoFocus + /> +
+ +
+ {searchingCustomer ? ( +
กำลังค้นหา...
+ ) : customerResults.length > 0 ? ( + customerResults.map(cust => ( + + )) + ) : ( +
+ {customerSearch ? 'ไม่พบข้อมูลสมาชิก' : 'พิมพ์เบอร์เพื่อค้นหา'} +
+ )} +
+ + +
+ )} +
+
+ )} + + {/* MODAL 3: Checkout & Payment Modal */} + {isPaymentOpen && ( +
+
+ {/* Left Col: Order Summary & Payment Method Tabs */} +
+
+
+ ชำระเงิน + + {cart.reduce((s, i) => s + i.quantity, 0)} รายการ + +
+ +
+ ยอดที่ต้องชำระสุทธิ + + {formatPrice(grandTotal)} + +
+ + {/* Payment Methods */} +
+ + + + + + + +
+
+ +
+ ลูกค้า: {selectedCustomer?.displayName || 'ลูกค้าหน้าร้าน (Takeaway)'} +
+
+ + {/* Right Col: Payment Input / Numpad / QR */} +
+
+

รายละเอียดการชำระเงิน

+ +
+ + {/* Cash Mode UI */} + {paymentMethod === 'CASH' && ( +
+
+ +
+ ฿ + setCashTendered(e.target.value)} + className="w-full bg-night-950 border border-white/10 rounded-2xl pl-10 pr-4 py-3 text-2xl font-black text-white focus:border-emerald-500 outline-none" + autoFocus + /> +
+
+ + {/* Quick Cash Buttons */} +
+ + {[100, 500, 1000].map(val => ( + + ))} +
+ + {/* Change Calculation Display */} +
+ เงินทอน (Change Due) + + {cashGivenNumber >= grandTotal ? formatPrice(changeDue) : '—'} + +
+ + {cashGivenNumber < grandTotal && ( +
+ + ยังขาดอีก ฿{(grandTotal - cashGivenNumber).toLocaleString()} +
+ )} +
+ )} + + {/* PromptPay QR UI */} + {paymentMethod === 'PROMPTPAY' && ( +
+
+ + PromptPay QR +
+
+ {formatPrice(grandTotal)} + ให้ลูกค้าสแกน QR Code จากแอปธนาคาร +
+
+ )} + + {/* Credit Card / EDC UI */} + {paymentMethod === 'CREDIT_CARD' && ( +
+ + รูดบัตรชำระเงินที่เครื่อง EDC + เมื่อทำรายการสำเร็จ ให้กดยืนยันการขายด้านล่าง +
+ )} + + {/* Transfer UI */} + {paymentMethod === 'TRANSFER' && ( +
+ + ตรวจสอบสลิปโอนเงิน + ยอดเงินเข้าบัญชีเรียบร้อยแล้ว กดยืนยันการขาย +
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+
+ )} + + {/* MODAL 4: Receipt & Print Slip Modal */} + {receiptModalOpen && completedOrder && ( +
+
+
+
+ +
+

บันทึกการขายสำเร็จ

+

บิลเลขที่ #{completedOrder.id}

+
+ + {/* Printable Receipt Preview (80mm formatted) */} +
+
+
{storeName}
+
TAX INVOICE / RECEIPT
+
+ {new Date(completedOrder.createdAt).toLocaleString('th-TH')} +
+
+ +
+ บิล: #{completedOrder.id} + แคชเชียร์: {cashier?.name || 'Admin'} +
+ {completedOrder.customerName && ( +
+ ลูกค้า: {completedOrder.customerName} +
+ )} + +
+ {completedOrder.items?.map((item, idx) => ( +
+
+
{item.productName}
+ {item.variantName &&
{item.variantName}
} +
+ {item.quantity} x {formatPrice(item.priceAtPurchase)} +
+
+
+ {formatPrice(item.priceAtPurchase * item.quantity)} +
+
+ ))} +
+ +
+
+ รวมเป็นเงิน + {formatPrice(completedOrder.subtotal || completedOrder.totalAmount)} +
+ {completedOrder.pointsDiscount && completedOrder.pointsDiscount > 0 ? ( +
+ ส่วนลดแต้ม ({completedOrder.pointsUsed} แต้ม) + -{formatPrice(completedOrder.pointsDiscount)} +
+ ) : null} +
+ ยอดสุทธิ + {formatPrice(completedOrder.totalAmount)} +
+
+ วิธีชำระ + {completedOrder.paymentMethod || 'เงินสด'} +
+ {completedOrder.receivedAmount ? ( +
+ รับเงินมา + {formatPrice(completedOrder.receivedAmount)} +
+ ) : null} + {completedOrder.changeAmount ? ( +
+ เงินทอน + {formatPrice(completedOrder.changeAmount)} +
+ ) : null} +
+ + {completedOrder.pointsEarned && completedOrder.pointsEarned > 0 ? ( +
+ ★ ได้รับแต้มสะสมเพิ่ม {completedOrder.pointsEarned} แต้ม ★ +
+ ) : null} + +
+ ขอบคุณที่ใช้บริการ / Thank You +
+
+ + {/* Actions */} +
+ + +
+
+
+ )} + + {/* Thermal Print Media Query Style */} + +
+ ) +} diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1bc6ddf..143de7b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -313,11 +313,12 @@ model NewsletterSubscriber { } model Order { - id Int @id @default(autoincrement()) - customerName String - customerEmail String - customerPhone String - shippingAddress String + id Int @id @default(autoincrement()) + channel String @default("ONLINE") // "ONLINE" | "POS" + customerName String? + customerEmail String? + customerPhone String? + shippingAddress String? subtotal Float @default(0) promoLevel1Name String? @@ -332,9 +333,13 @@ model Order { pointsDiscount Float @default(0) pointsEarned Int @default(0) - shippingFee Float @default(35) + shippingFee Float @default(0) totalAmount Float status String @default("PENDING_PAYMENT") + paymentMethod String? @default("CASH") + receivedAmount Float? + changeAmount Float? + cashierId Int? paymentSlipUrl String? trackingNo String? @@ -344,6 +349,9 @@ model Order { items OrderItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([channel]) + @@index([createdAt]) } model OrderItem { diff --git a/apps/backend/src/routes/customers.ts b/apps/backend/src/routes/customers.ts index 04d9bb7..9cf2548 100644 --- a/apps/backend/src/routes/customers.ts +++ b/apps/backend/src/routes/customers.ts @@ -66,4 +66,34 @@ export const customersRoute = new Elysia({ prefix: "/customers" }) pointsDelta: t.Number(), reason: t.Optional(t.String()) }) + }) + .post("/", async ({ body, set }) => { + try { + const { displayName, phone, email } = body as { displayName?: string, phone?: string, email?: string }; + + // Auto-generate a unique lineUserId placeholder for in-store/POS customers + const lineUserId = `POS_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + + const newCustomer = await prisma.customer.create({ + data: { + lineUserId, + displayName: displayName || "ลูกค้าหน้าร้าน", + phone: phone || null, + email: email || null, + points: 0 + } + }); + return newCustomer; + } catch (err: any) { + console.error("[Customers] Create error:", err); + set.status = 400; + return { error: err.message || "Failed to create customer" }; + } + }, { + body: t.Object({ + displayName: t.Optional(t.String()), + phone: t.Optional(t.String()), + email: t.Optional(t.String()) + }) }); + diff --git a/apps/backend/src/routes/orders.ts b/apps/backend/src/routes/orders.ts index 9ccd386..8118759 100644 --- a/apps/backend/src/routes/orders.ts +++ b/apps/backend/src/routes/orders.ts @@ -48,13 +48,17 @@ export const ordersRoute = new Elysia({ prefix: "/orders" }) }) } + const isPos = data.channel === "POS"; + const initialStatus = isPos ? "COMPLETED" : "PENDING_PAYMENT"; + const order = await prisma.order.create({ data: { + channel: data.channel || "ONLINE", customerId: data.customerId, - customerName: data.customerName, - customerEmail: data.customerEmail, - customerPhone: data.customerPhone, - shippingAddress: data.shippingAddress, + customerName: data.customerName || (isPos ? "ลูกค้าหน้าร้าน (Walk-in)" : null), + customerEmail: data.customerEmail || null, + customerPhone: data.customerPhone || null, + shippingAddress: data.shippingAddress || (isPos ? "หน้าร้าน (Takeaway)" : null), subtotal: data.subtotal || 0, promoLevel1Name: data.promoLevel1Name, promoLevel1Disc: data.promoLevel1Disc || 0, @@ -65,8 +69,13 @@ export const ordersRoute = new Elysia({ prefix: "/orders" }) pointsUsed: data.pointsUsed || 0, pointsDiscount: data.pointsDiscount || 0, pointsEarned: pointsEarned, - shippingFee: data.shippingFee, + shippingFee: data.shippingFee || 0, totalAmount: data.totalAmount, + status: initialStatus, + paymentMethod: data.paymentMethod || (isPos ? "CASH" : null), + receivedAmount: data.receivedAmount, + changeAmount: data.changeAmount, + cashierId: data.cashierId, items: { create: data.items.map((item: any) => ({ productId: item.productId, @@ -81,6 +90,33 @@ export const ordersRoute = new Elysia({ prefix: "/orders" }) }, include: { items: true } }); + + // For POS orders: Deduct stock immediately and credit points + if (isPos) { + for (const item of data.items) { + if (item.variantId) { + try { + await prisma.productVariant.update({ + where: { id: item.variantId }, + data: { stock: { decrement: item.quantity } } + }); + } catch (err) { + console.warn(`[POS] Failed to decrement stock for variant ${item.variantId}:`, err); + } + } + } + + if (data.customerId && pointsEarned > 0) { + try { + await prisma.customer.update({ + where: { id: data.customerId }, + data: { points: { increment: pointsEarned } } + }); + } catch (err) { + console.warn(`[POS] Failed to credit points for customer ${data.customerId}:`, err); + } + } + } // If a coupon was used, increment its usedCount if (data.couponCode) { @@ -107,13 +143,18 @@ export const ordersRoute = new Elysia({ prefix: "/orders" }) } }, { body: t.Object({ + channel: t.Optional(t.String()), customerId: t.Optional(t.Nullable(t.Number())), - customerName: t.String(), - customerEmail: t.String(), - customerPhone: t.String(), - shippingAddress: t.String(), + customerName: t.Optional(t.Nullable(t.String())), + customerEmail: t.Optional(t.Nullable(t.String())), + customerPhone: t.Optional(t.Nullable(t.String())), + shippingAddress: t.Optional(t.Nullable(t.String())), + paymentMethod: t.Optional(t.Nullable(t.String())), + receivedAmount: t.Optional(t.Nullable(t.Number())), + changeAmount: t.Optional(t.Nullable(t.Number())), + cashierId: t.Optional(t.Nullable(t.Number())), totalAmount: t.Number(), - shippingFee: t.Number(), + shippingFee: t.Optional(t.Number()), subtotal: t.Optional(t.Number()), promoLevel1Name: t.Optional(t.Nullable(t.String())), promoLevel1Disc: t.Optional(t.Number()), diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index d55a515..e940607 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -133,8 +133,13 @@ export const productsRoute = new Elysia({ prefix: "/products" }) { translations: { some: { name: { contains: search, mode: "insensitive" as const } } } }, { brand: { name: { contains: search, mode: "insensitive" as const } } }, { category: { name: { contains: search, mode: "insensitive" as const } } }, + { variants: { some: { OR: [ + { barcode: { contains: search, mode: "insensitive" as const } }, + { sku: { contains: search, mode: "insensitive" as const } } + ] } } }, ], }), + } as const; const [products, total] = await Promise.all([ diff --git a/packages/types/index.ts b/packages/types/index.ts index a927e06..f363998 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -267,6 +267,11 @@ export interface Order { totalAmount: number shippingFee: number status: string + channel?: string + paymentMethod?: string | null + receivedAmount?: number | null + changeAmount?: number | null + cashierId?: number | null paymentSlipUrl?: string | null trackingNo?: string | null customerId?: number | null @@ -276,6 +281,7 @@ export interface Order { updatedAt: string } + export interface Article { id: number slug: string