523 lines
20 KiB
TypeScript
523 lines
20 KiB
TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
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 { Input } from '@/components/ui/input'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import { X, Banknote, CreditCard, FileText, Ban, UserRound, Tag } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useState } from 'react'
|
|
import { POSPaymentDialog } from './pos-payment-dialog'
|
|
import { POSCustomerDialog } from './pos-customer-dialog'
|
|
import { ManagerOverrideDialog, requiresOverride, requiresDiscountOverride } from './pos-manager-override'
|
|
import type { TransactionLineItem } from '@/api/pos'
|
|
|
|
interface POSCartPanelProps {
|
|
transaction: Transaction | null
|
|
}
|
|
|
|
export function POSCartPanel({ transaction }: POSCartPanelProps) {
|
|
const queryClient = useQueryClient()
|
|
const { currentTransactionId, setTransaction, accountName, accountPhone, accountEmail } = usePOSStore()
|
|
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
|
|
const [customerOpen, setCustomerOpen] = useState(false)
|
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
|
const [priceItemId, setPriceItemId] = useState<string | null>(null)
|
|
const [pendingDiscount, setPendingDiscount] = useState<{ lineItemId: string; amount: number; reason: string } | null>(null)
|
|
const [pendingOrderDiscount, setPendingOrderDiscount] = useState<{ amount: number; reason: string } | null>(null)
|
|
const [discountOverrideOpen, setDiscountOverrideOpen] = useState(false)
|
|
const lineItems = transaction?.lineItems ?? []
|
|
|
|
const drawerSessionId = usePOSStore((s) => s.drawerSessionId)
|
|
const drawerOpen = !!drawerSessionId
|
|
|
|
const removeItemMutation = useMutation({
|
|
mutationFn: (lineItemId: string) =>
|
|
posMutations.removeLineItem(currentTransactionId!, lineItemId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const discountMutation = useMutation({
|
|
mutationFn: (data: { lineItemId: string; amount: number; reason: string }) =>
|
|
posMutations.applyDiscount(currentTransactionId!, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
|
setPriceItemId(null)
|
|
toast.success('Price adjusted')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const orderDiscountMutation = useMutation({
|
|
mutationFn: async ({ amount, reason }: { amount: number; reason: string }) => {
|
|
// Distribute discount proportionally across all line items
|
|
let remaining = amount
|
|
for (let i = 0; i < lineItems.length; i++) {
|
|
const item = lineItems[i]
|
|
const itemTotal = parseFloat(item.unitPrice) * item.qty
|
|
const isLast = i === lineItems.length - 1
|
|
const share = isLast ? remaining : Math.round((itemTotal / subtotal) * amount * 100) / 100
|
|
remaining -= share
|
|
if (share > 0) {
|
|
await posMutations.applyDiscount(currentTransactionId!, { lineItemId: item.id, amount: share, reason })
|
|
}
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
|
|
toast.success('Order discount applied')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const voidMutation = useMutation({
|
|
mutationFn: () => posMutations.void(currentTransactionId!),
|
|
onSuccess: () => {
|
|
setTransaction(null)
|
|
toast.success('Transaction voided')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const subtotal = parseFloat(transaction?.subtotal ?? '0')
|
|
const discountTotal = parseFloat(transaction?.discountTotal ?? '0')
|
|
const taxTotal = parseFloat(transaction?.taxTotal ?? '0')
|
|
const total = parseFloat(transaction?.total ?? '0')
|
|
const hasItems = lineItems.length > 0
|
|
const isPending = transaction?.status === 'pending'
|
|
|
|
function handlePaymentComplete() {
|
|
setPaymentMethod(null)
|
|
setTransaction(null)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-card">
|
|
{/* Header */}
|
|
<div className="p-3 border-b border-border">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="font-semibold text-base">Current Sale</h2>
|
|
{transaction && (
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
{transaction.transactionNumber}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setCustomerOpen(true)}
|
|
className="flex items-start gap-1.5 mt-1 text-xs text-muted-foreground hover:text-foreground text-left"
|
|
>
|
|
<UserRound className="h-3 w-3 mt-0.5 shrink-0" />
|
|
{accountName ? (
|
|
<span>
|
|
<span className="font-medium text-foreground">{accountName}</span>
|
|
{(accountPhone || accountEmail) && (
|
|
<span className="block text-[11px]">
|
|
{[accountPhone, accountEmail].filter(Boolean).join(' · ')}
|
|
</span>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<span>Walk-in — tap to add customer</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Line items */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{lineItems.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
No items yet
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{lineItems.map((item) => {
|
|
const unitPrice = parseFloat(item.unitPrice)
|
|
const discount = parseFloat(item.discountAmount)
|
|
const hasDiscount = discount > 0
|
|
const listTotal = unitPrice * item.qty
|
|
const discountPct = listTotal > 0 ? Math.round((discount / listTotal) * 100) : 0
|
|
|
|
return (
|
|
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{item.description}</p>
|
|
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
|
<span>{item.qty} x ${unitPrice.toFixed(2)}</span>
|
|
{hasDiscount && (
|
|
<span className="text-green-600">-${discount.toFixed(2)} ({discountPct}%)</span>
|
|
)}
|
|
{parseFloat(item.taxAmount) > 0 && (
|
|
<span>tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="text-sm font-medium tabular-nums">
|
|
${parseFloat(item.lineTotal).toFixed(2)}
|
|
</span>
|
|
{isPending && (
|
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
|
|
<PriceAdjuster
|
|
item={item}
|
|
open={priceItemId === item.id}
|
|
onOpenChange={(o) => setPriceItemId(o ? item.id : null)}
|
|
onApply={(amount, reason) => {
|
|
const pct = listTotal > 0 ? (amount / listTotal) * 100 : 0
|
|
if (requiresDiscountOverride(pct)) {
|
|
setPendingDiscount({ lineItemId: item.id, amount, reason })
|
|
setDiscountOverrideOpen(true)
|
|
} else {
|
|
discountMutation.mutate({ lineItemId: item.id, amount, reason })
|
|
}
|
|
}}
|
|
isPending={discountMutation.isPending}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => removeItemMutation.mutate(item.id)}
|
|
disabled={removeItemMutation.isPending}
|
|
>
|
|
<X className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Totals + payment */}
|
|
<div className="shrink-0 border-t border-border">
|
|
<div className="px-3 py-2 space-y-1 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Subtotal</span>
|
|
<span className="tabular-nums">${subtotal.toFixed(2)}</span>
|
|
</div>
|
|
{discountTotal > 0 && (
|
|
<div className="flex justify-between text-green-600">
|
|
<span>Discount</span>
|
|
<span className="tabular-nums">-${discountTotal.toFixed(2)}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Tax</span>
|
|
<span className="tabular-nums">${taxTotal.toFixed(2)}</span>
|
|
</div>
|
|
<Separator />
|
|
<div className="flex justify-between text-lg font-bold pt-1">
|
|
<span>Total</span>
|
|
<span className="tabular-nums">${total.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Order discount button */}
|
|
{hasItems && isPending && (
|
|
<div className="px-3 pb-1">
|
|
<OrderDiscountButton
|
|
subtotal={subtotal}
|
|
onApply={(amount, reason) => {
|
|
const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0
|
|
if (requiresDiscountOverride(pct)) {
|
|
setPendingOrderDiscount({ amount, reason })
|
|
setDiscountOverrideOpen(true)
|
|
} else {
|
|
orderDiscountMutation.mutate({ amount, reason })
|
|
}
|
|
}}
|
|
isPending={orderDiscountMutation.isPending}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Payment buttons */}
|
|
<div className="p-3 space-y-2">
|
|
{!drawerOpen && hasItems && (
|
|
<p className="text-xs text-destructive text-center">Open the drawer before accepting payment</p>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
className="h-12 text-sm gap-2"
|
|
disabled={!hasItems || !isPending || !drawerOpen}
|
|
onClick={() => setPaymentMethod('cash')}
|
|
>
|
|
<Banknote className="h-4 w-4" />
|
|
Cash
|
|
</Button>
|
|
<Button
|
|
className="h-12 text-sm gap-2"
|
|
disabled={!hasItems || !isPending || !drawerOpen}
|
|
onClick={() => setPaymentMethod('card_present')}
|
|
>
|
|
<CreditCard className="h-4 w-4" />
|
|
Card
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-12 text-sm gap-2"
|
|
disabled={!hasItems || !isPending || !drawerOpen}
|
|
onClick={() => setPaymentMethod('check')}
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
Check
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
className="h-12 text-sm gap-2"
|
|
disabled={!hasItems || !isPending}
|
|
onClick={() => {
|
|
if (requiresOverride('void_transaction')) {
|
|
setOverrideOpen(true)
|
|
} else {
|
|
voidMutation.mutate()
|
|
}
|
|
}}
|
|
>
|
|
<Ban className="h-4 w-4" />
|
|
Void
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment dialog */}
|
|
{paymentMethod && transaction && (
|
|
<POSPaymentDialog
|
|
open={!!paymentMethod}
|
|
onOpenChange={(open) => { if (!open) setPaymentMethod(null) }}
|
|
paymentMethod={paymentMethod}
|
|
transaction={transaction}
|
|
onComplete={handlePaymentComplete}
|
|
/>
|
|
)}
|
|
|
|
{/* Customer dialog */}
|
|
<POSCustomerDialog open={customerOpen} onOpenChange={setCustomerOpen} />
|
|
|
|
{/* Manager override for void */}
|
|
<ManagerOverrideDialog
|
|
open={overrideOpen}
|
|
onOpenChange={setOverrideOpen}
|
|
action="Void transaction"
|
|
onAuthorized={() => voidMutation.mutate()}
|
|
/>
|
|
|
|
{/* Manager override for discount */}
|
|
<ManagerOverrideDialog
|
|
open={discountOverrideOpen}
|
|
onOpenChange={setDiscountOverrideOpen}
|
|
action="Price adjustment"
|
|
onAuthorized={() => {
|
|
if (pendingDiscount) {
|
|
discountMutation.mutate(pendingDiscount)
|
|
setPendingDiscount(null)
|
|
} else if (pendingOrderDiscount) {
|
|
orderDiscountMutation.mutate(pendingOrderDiscount)
|
|
setPendingOrderDiscount(null)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Order Discount Button ---
|
|
|
|
function OrderDiscountButton({ subtotal, onApply, isPending }: {
|
|
subtotal: number
|
|
onApply: (amount: number, reason: string) => void
|
|
isPending: boolean
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const [mode, setMode] = useState<AdjustMode>('percent')
|
|
const [value, setValue] = useState('')
|
|
|
|
function calculate() {
|
|
const v = parseFloat(value) || 0
|
|
if (mode === 'amount_off') return Math.min(v, subtotal)
|
|
if (mode === 'set_price') return Math.max(0, subtotal - v)
|
|
return Math.round(subtotal * (v / 100) * 100) / 100
|
|
}
|
|
|
|
const discountAmount = calculate()
|
|
|
|
function handleApply() {
|
|
if (discountAmount <= 0) return
|
|
const reason = mode === 'percent' ? `${parseFloat(value)}% order discount` : mode === 'set_price' ? `Order total set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} order discount`
|
|
onApply(discountAmount, reason)
|
|
setValue('')
|
|
setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setValue('') }}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm" className="w-full gap-2 text-xs h-8">
|
|
<Tag className="h-3 w-3" />Order Discount
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-3" side="top" align="center">
|
|
<div className="space-y-3">
|
|
<div className="text-xs text-muted-foreground">
|
|
Subtotal: <span className="font-medium text-foreground">${subtotal.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex rounded-md border overflow-hidden text-xs">
|
|
{([
|
|
{ key: 'percent' as const, label: '% Off' },
|
|
{ key: 'amount_off' as const, label: '$ Off' },
|
|
{ key: 'set_price' as const, label: 'Set Total' },
|
|
]).map((m) => (
|
|
<button
|
|
key={m.key}
|
|
type="button"
|
|
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
|
|
onClick={() => { setMode(m.key); setValue('') }}
|
|
>
|
|
{m.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
className="h-9"
|
|
autoFocus
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
|
|
/>
|
|
{value && discountAmount > 0 && (
|
|
<div className="text-xs flex justify-between font-medium">
|
|
<span className="text-green-600">-${discountAmount.toFixed(2)}</span>
|
|
<span>New total: ${(subtotal - discountAmount).toFixed(2)}</span>
|
|
</div>
|
|
)}
|
|
<Button size="sm" className="w-full" onClick={handleApply} disabled={isPending || discountAmount <= 0}>
|
|
{isPending ? 'Applying...' : 'Apply Discount'}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
// --- Price Adjuster Popover ---
|
|
|
|
type AdjustMode = 'amount_off' | 'set_price' | 'percent'
|
|
|
|
function PriceAdjuster({ item, open, onOpenChange, onApply, isPending }: {
|
|
item: TransactionLineItem
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onApply: (amount: number, reason: string) => void
|
|
isPending: boolean
|
|
}) {
|
|
const [mode, setMode] = useState<AdjustMode>('percent')
|
|
const [value, setValue] = useState('')
|
|
|
|
const unitPrice = parseFloat(item.unitPrice)
|
|
const listTotal = unitPrice * item.qty
|
|
|
|
function calculate(): { discountAmount: number; salePrice: number; pct: number } {
|
|
const v = parseFloat(value) || 0
|
|
if (mode === 'amount_off') {
|
|
const d = Math.min(v, listTotal)
|
|
return { discountAmount: d, salePrice: listTotal - d, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
|
|
}
|
|
if (mode === 'set_price') {
|
|
const d = Math.max(0, listTotal - v)
|
|
return { discountAmount: d, salePrice: v, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
|
|
}
|
|
// percent
|
|
const d = Math.round(listTotal * (v / 100) * 100) / 100
|
|
return { discountAmount: d, salePrice: listTotal - d, pct: v }
|
|
}
|
|
|
|
const calc = calculate()
|
|
|
|
function handleApply() {
|
|
if (calc.discountAmount <= 0) return
|
|
const reason = mode === 'percent' ? `${parseFloat(value)}% off` : mode === 'set_price' ? `Price set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} off`
|
|
onApply(calc.discountAmount, reason)
|
|
setValue('')
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setValue('') }}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<Tag className="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-3" side="left" align="start">
|
|
<div className="space-y-3">
|
|
<div className="text-xs text-muted-foreground">
|
|
List: <span className="font-medium text-foreground">${listTotal.toFixed(2)}</span>
|
|
</div>
|
|
|
|
{/* Mode tabs */}
|
|
<div className="flex rounded-md border overflow-hidden text-xs">
|
|
{([
|
|
{ key: 'percent' as const, label: '% Off' },
|
|
{ key: 'amount_off' as const, label: '$ Off' },
|
|
{ key: 'set_price' as const, label: 'Set Price' },
|
|
]).map((m) => (
|
|
<button
|
|
key={m.key}
|
|
type="button"
|
|
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
|
|
onClick={() => { setMode(m.key); setValue('') }}
|
|
>
|
|
{m.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
className="h-9"
|
|
autoFocus
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
|
|
/>
|
|
|
|
{value && parseFloat(value) > 0 && (
|
|
<div className="text-xs space-y-0.5">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Discount</span>
|
|
<span className="text-green-600">-${calc.discountAmount.toFixed(2)} ({calc.pct.toFixed(0)}%)</span>
|
|
</div>
|
|
<div className="flex justify-between font-medium">
|
|
<span>Sale Price</span>
|
|
<span>${calc.salePrice.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={handleApply}
|
|
disabled={isPending || !value || calc.discountAmount <= 0}
|
|
>
|
|
{isPending ? 'Applying...' : 'Apply'}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|