Files
lunarfront-app/packages/admin/src/components/pos/pos-customer-dialog.tsx
ryan 0fd73015f7 fix: customer history query, seed transactions tied to accounts
- Fix customerHistoryOptions closure bug (historySearch was inaccessible)
- Pass itemSearch as parameter instead of capturing from outer scope
- Seed 5 completed transactions tied to accounts (Smith, Johnson, Garcia, Chen)
- Seed admin user with employee number 1001 and PIN 1234

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00

255 lines
9.3 KiB
TypeScript

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, itemSearch?: string) {
return queryOptions({
queryKey: ['pos', 'customer-history', accountId, itemSearch ?? ''],
queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', {
accountId,
limit: 10,
sort: 'created_at',
order: 'desc',
...(itemSearch ? { itemSearch } : {}),
}),
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, historySearch || undefined))
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<string | null>(null)
// Fetch detail for expanded transaction
const { data: txnDetail } = useQuery({
queryKey: ['pos', 'transaction-detail', expandedTxn],
queryFn: () => api.get<Transaction>(`/v1/transactions/${expandedTxn}`),
enabled: !!expandedTxn,
})
const toggleExpand = useCallback((id: string) => {
setExpandedTxn((prev) => prev === id ? null : id)
}, [])
// History view
if (showHistory && accountId) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Order History {accountName}</span>
<Button variant="ghost" size="sm" onClick={() => { setShowHistory(false); setExpandedTxn(null) }}>Back</Button>
</DialogTitle>
</DialogHeader>
{/* Search items in history */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={historySearch}
onChange={(e) => setHistorySearch(e.target.value)}
placeholder="Search items (e.g. strings, bow)..."
className="pl-10 h-10 text-sm"
/>
</div>
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{historySearch ? `No orders with "${historySearch}"` : 'No transactions found'}
</p>
) : (
<div className="divide-y divide-border">
{history.map((txn) => (
<div key={txn.id}>
<button
onClick={() => toggleExpand(txn.id)}
className="w-full text-left py-2.5 px-1 hover:bg-accent/50 rounded transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-mono">{txn.transactionNumber}</span>
<span className="text-sm font-semibold">${parseFloat(txn.total).toFixed(2)}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant={txn.status === 'completed' ? 'default' : 'outline'} className="text-[10px]">
{txn.status}
</Badge>
{txn.paymentMethod && (
<span className="text-xs text-muted-foreground">{txn.paymentMethod.replace('_', ' ')}</span>
)}
<span className="text-xs text-muted-foreground ml-auto">
{new Date(txn.completedAt ?? txn.createdAt).toLocaleDateString()}
</span>
</div>
</button>
{expandedTxn === txn.id && txnDetail?.lineItems && (
<div className="px-3 pb-2 space-y-1">
{txnDetail.lineItems.map((item) => (
<div key={item.id} className="flex justify-between text-xs text-muted-foreground">
<span>{item.qty} x {item.description}</span>
<span>${parseFloat(item.lineTotal).toFixed(2)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Customer</DialogTitle>
</DialogHeader>
{/* Current selection */}
{accountId && (
<>
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
<div>
<p className="font-medium text-sm">{accountName}</p>
<p className="text-xs text-muted-foreground">Selected customer</p>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4 mr-1" />
History
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleClear}>
<X className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<Separator />
</>
)}
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, phone, email, account #..."
className="pl-10 h-11"
autoFocus
/>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Searching...</p>
) : search.length >= 2 && accounts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No customers found</p>
) : (
<div className="divide-y divide-border">
{accounts.map((account) => (
<button
key={account.id}
onClick={() => handleSelect(account)}
className="w-full text-left px-2 py-3 hover:bg-accent rounded-md transition-colors"
>
<p className="font-medium text-sm">{account.name}</p>
<div className="flex gap-3 text-xs text-muted-foreground mt-0.5">
{account.phone && <span>{account.phone}</span>}
{account.email && <span>{account.email}</span>}
{account.accountNumber && <span>#{account.accountNumber}</span>}
</div>
</button>
))}
</div>
)}
</div>
{/* Walk-in button */}
{accountId && (
<Button variant="outline" className="w-full h-11" onClick={handleClear}>
Clear Customer (Walk-in)
</Button>
)}
</DialogContent>
</Dialog>
)
}