From d21972212bf0a7670d0f8dbdffcae3f008856610 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 21:07:24 +0000 Subject: [PATCH] feat: customer lookup from POS with order history and item search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Customer dialog in cart panel: search accounts by name, phone, email, account # - Selected customer shown with name, phone, email in cart header - accountId passed when creating transactions - Order history view: tap a transaction to expand and see line items - Item search in history (e.g. "strings") โ€” filters orders containing that item - Backend: add accountId and itemSearch filters to transaction list endpoint - itemSearch uses EXISTS subquery on line item descriptions (ILIKE) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/admin/src/api/pos.ts | 2 +- .../src/components/pos/pos-cart-panel.tsx | 27 +- .../components/pos/pos-customer-dialog.tsx | 254 ++++++++++++++++++ .../src/components/pos/pos-item-panel.tsx | 5 +- packages/admin/src/stores/pos.store.ts | 14 +- .../backend/src/routes/v1/transactions.ts | 2 + .../src/services/transaction.service.ts | 11 + 7 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 packages/admin/src/components/pos/pos-customer-dialog.tsx diff --git a/packages/admin/src/api/pos.ts b/packages/admin/src/api/pos.ts index 2d32aff..871e1ee 100644 --- a/packages/admin/src/api/pos.ts +++ b/packages/admin/src/api/pos.ts @@ -151,7 +151,7 @@ export function discountListOptions() { // --- Mutations --- export const posMutations = { - createTransaction: (data: { transactionType: string; locationId?: string }) => + createTransaction: (data: { transactionType: string; locationId?: string; accountId?: string }) => api.post('/v1/transactions', data), addLineItem: (txnId: string, data: { productId?: string; inventoryUnitId?: string; description: string; qty: number; unitPrice: number }) => diff --git a/packages/admin/src/components/pos/pos-cart-panel.tsx b/packages/admin/src/components/pos/pos-cart-panel.tsx index 36caad4..d51c257 100644 --- a/packages/admin/src/components/pos/pos-cart-panel.tsx +++ b/packages/admin/src/components/pos/pos-cart-panel.tsx @@ -3,10 +3,11 @@ import { usePOSStore } from '@/stores/pos.store' import { posMutations, posKeys, type Transaction } from '@/api/pos' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' -import { X, Banknote, CreditCard, FileText, Ban } from 'lucide-react' +import { X, Banknote, CreditCard, FileText, Ban, UserRound } from 'lucide-react' import { toast } from 'sonner' import { useState } from 'react' import { POSPaymentDialog } from './pos-payment-dialog' +import { POSCustomerDialog } from './pos-customer-dialog' interface POSCartPanelProps { transaction: Transaction | null @@ -14,8 +15,9 @@ interface POSCartPanelProps { export function POSCartPanel({ transaction }: POSCartPanelProps) { const queryClient = useQueryClient() - const { currentTransactionId, setTransaction } = usePOSStore() + const { currentTransactionId, setTransaction, accountName, accountPhone, accountEmail } = usePOSStore() const [paymentMethod, setPaymentMethod] = useState(null) + const [customerOpen, setCustomerOpen] = useState(false) const lineItems = transaction?.lineItems ?? [] const drawerSessionId = usePOSStore((s) => s.drawerSessionId) @@ -63,6 +65,24 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { )} + {/* Line items */} @@ -182,6 +202,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { onComplete={handlePaymentComplete} /> )} + + {/* Customer dialog */} + ) } diff --git a/packages/admin/src/components/pos/pos-customer-dialog.tsx b/packages/admin/src/components/pos/pos-customer-dialog.tsx new file mode 100644 index 0000000..a2bf740 --- /dev/null +++ b/packages/admin/src/components/pos/pos-customer-dialog.tsx @@ -0,0 +1,254 @@ +import { useState, useCallback } from 'react' +import { useQuery } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { usePOSStore } from '@/stores/pos.store' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Search, X, History } from 'lucide-react' + +interface Account { + id: string + name: string + email: string | null + phone: string | null + accountNumber: string | null +} + +interface TransactionLineItem { + id: string + description: string + qty: number + unitPrice: string + lineTotal: string +} + +interface Transaction { + id: string + transactionNumber: string + total: string + status: string + paymentMethod: string | null + transactionType: string + completedAt: string | null + createdAt: string + lineItems?: TransactionLineItem[] +} + +function accountSearchOptions(search: string) { + return queryOptions({ + queryKey: ['pos', 'accounts', search], + queryFn: () => api.get<{ data: Account[] }>('/v1/accounts', { q: search, limit: 10 }), + enabled: search.length >= 2, + }) +} + +function customerHistoryOptions(accountId: string | null) { + return queryOptions({ + queryKey: ['pos', 'customer-history', accountId], + queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', { + accountId, + limit: 10, + sort: 'created_at', + order: 'desc', + ...(historySearch ? { itemSearch: historySearch } : {}), + }), + enabled: !!accountId, + }) +} + +interface POSCustomerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function POSCustomerDialog({ open, onOpenChange }: POSCustomerDialogProps) { + const { accountId, accountName, setAccount, clearAccount } = usePOSStore() + const [search, setSearch] = useState('') + const [showHistory, setShowHistory] = useState(false) + const [historySearch, setHistorySearch] = useState('') + + const { data: searchData, isLoading } = useQuery(accountSearchOptions(search)) + const accounts = searchData?.data ?? [] + + const { data: historyData } = useQuery(customerHistoryOptions(showHistory ? accountId : null)) + const history = historyData?.data ?? [] + + function handleSelect(account: Account) { + setAccount(account.id, account.name, account.phone, account.email) + setSearch('') + setShowHistory(false) + onOpenChange(false) + } + + function handleClear() { + clearAccount() + setSearch('') + setShowHistory(false) + onOpenChange(false) + } + + const [expandedTxn, setExpandedTxn] = useState(null) + + // Fetch detail for expanded transaction + const { data: txnDetail } = useQuery({ + queryKey: ['pos', 'transaction-detail', expandedTxn], + queryFn: () => api.get(`/v1/transactions/${expandedTxn}`), + enabled: !!expandedTxn, + }) + + const toggleExpand = useCallback((id: string) => { + setExpandedTxn((prev) => prev === id ? null : id) + }, []) + + // History view + if (showHistory && accountId) { + return ( + + + + + Order History โ€” {accountName} + + + + {/* Search items in history */} +
+ + setHistorySearch(e.target.value)} + placeholder="Search items (e.g. strings, bow)..." + className="pl-10 h-10 text-sm" + /> +
+ +
+ {history.length === 0 ? ( +

+ {historySearch ? `No orders with "${historySearch}"` : 'No transactions found'} +

+ ) : ( +
+ {history.map((txn) => ( +
+ + {expandedTxn === txn.id && txnDetail?.lineItems && ( +
+ {txnDetail.lineItems.map((item) => ( +
+ {item.qty} x {item.description} + ${parseFloat(item.lineTotal).toFixed(2)} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+
+
+ ) + } + + return ( + + + + Customer + + + {/* Current selection */} + {accountId && ( + <> +
+
+

{accountName}

+

Selected customer

+
+
+ + +
+
+ + + )} + + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search by name, phone, email, account #..." + className="pl-10 h-11" + autoFocus + /> +
+ + {/* Results */} +
+ {isLoading ? ( +

Searching...

+ ) : search.length >= 2 && accounts.length === 0 ? ( +

No customers found

+ ) : ( +
+ {accounts.map((account) => ( + + ))} +
+ )} +
+ + {/* Walk-in button */} + {accountId && ( + + )} +
+
+ ) +} diff --git a/packages/admin/src/components/pos/pos-item-panel.tsx b/packages/admin/src/components/pos/pos-item-panel.tsx index 4949c18..0cdf94d 100644 --- a/packages/admin/src/components/pos/pos-item-panel.tsx +++ b/packages/admin/src/components/pos/pos-item-panel.tsx @@ -16,7 +16,7 @@ interface POSItemPanelProps { export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) { const queryClient = useQueryClient() - const { currentTransactionId, setTransaction, locationId } = usePOSStore() + const { currentTransactionId, setTransaction, locationId, accountId } = usePOSStore() const [search, setSearch] = useState('') const [customOpen, setCustomOpen] = useState(false) const [customDesc, setCustomDesc] = useState('') @@ -40,6 +40,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) { const txn = await posMutations.createTransaction({ transactionType: 'sale', locationId: locationId ?? undefined, + accountId: accountId ?? undefined, }) txnId = txn.id setTransaction(txnId) @@ -66,6 +67,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) { const txn = await posMutations.createTransaction({ transactionType: 'sale', locationId: locationId ?? undefined, + accountId: accountId ?? undefined, }) txnId = txn.id setTransaction(txnId) @@ -96,6 +98,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) { const txn = await posMutations.createTransaction({ transactionType: 'sale', locationId: locationId ?? undefined, + accountId: accountId ?? undefined, }) txnId = txn.id setTransaction(txnId) diff --git a/packages/admin/src/stores/pos.store.ts b/packages/admin/src/stores/pos.store.ts index 68310dd..38f4c6e 100644 --- a/packages/admin/src/stores/pos.store.ts +++ b/packages/admin/src/stores/pos.store.ts @@ -16,12 +16,18 @@ interface POSState { cashier: POSUser | null token: string | null lastActivity: number + accountId: string | null + accountName: string | null + accountPhone: string | null + accountEmail: string | null setTransaction: (id: string | null) => void setLocation: (id: string) => void setDrawerSession: (id: string | null) => void unlock: (user: POSUser, token: string) => void lock: () => void touchActivity: () => void + setAccount: (id: string, name: string, phone?: string | null, email?: string | null) => void + clearAccount: () => void reset: () => void } @@ -33,11 +39,17 @@ export const usePOSStore = create((set) => ({ cashier: null, token: null, lastActivity: Date.now(), + accountId: null, + accountName: null, + accountPhone: null, + accountEmail: null, setTransaction: (id) => set({ currentTransactionId: id }), setLocation: (id) => set({ locationId: id }), setDrawerSession: (id) => set({ drawerSessionId: id }), unlock: (user, token) => set({ locked: false, cashier: user, token, lastActivity: Date.now() }), lock: () => set({ locked: true, currentTransactionId: null }), touchActivity: () => set({ lastActivity: Date.now() }), - reset: () => set({ currentTransactionId: null }), + setAccount: (id, name, phone, email) => set({ accountId: id, accountName: name, accountPhone: phone ?? null, accountEmail: email ?? null }), + clearAccount: () => set({ accountId: null, accountName: null, accountPhone: null, accountEmail: null }), + reset: () => set({ currentTransactionId: null, accountId: null, accountName: null, accountPhone: null, accountEmail: null }), })) diff --git a/packages/backend/src/routes/v1/transactions.ts b/packages/backend/src/routes/v1/transactions.ts index 4b94d1f..f510865 100644 --- a/packages/backend/src/routes/v1/transactions.ts +++ b/packages/backend/src/routes/v1/transactions.ts @@ -27,6 +27,8 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => { status: query.status, transactionType: query.transactionType, locationId: query.locationId, + accountId: query.accountId, + itemSearch: query.itemSearch, } const result = await TransactionService.list(app.db, params, filters) diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts index 959666c..ae63543 100644 --- a/packages/backend/src/services/transaction.service.ts +++ b/packages/backend/src/services/transaction.service.ts @@ -390,12 +390,20 @@ export const TransactionService = { status?: string transactionType?: string locationId?: string + accountId?: string + itemSearch?: string }) { const conditions: ReturnType[] = [] if (params.q) { conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!) } + if (filters?.itemSearch) { + const term = `%${filters.itemSearch}%` + conditions.push( + sql`EXISTS (SELECT 1 FROM ${transactionLineItems} WHERE ${transactionLineItems.transactionId} = ${transactions.id} AND ${transactionLineItems.description} ILIKE ${term})` + ) + } if (filters?.status) { conditions.push(eq(transactions.status, filters.status as any)) } @@ -405,6 +413,9 @@ export const TransactionService = { if (filters?.locationId) { conditions.push(eq(transactions.locationId, filters.locationId)) } + if (filters?.accountId) { + conditions.push(eq(transactions.accountId, filters.accountId)) + } const where = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions)