Registers: - New register table with location association - CRUD service + API routes (POST/GET/PATCH/DELETE /registers) - Drawer sessions now link to a register via registerId - Register ID persisted in localStorage per device X/Z Reports: - ReportService with getDrawerReport() (X or Z depending on session state) - Z report auto-displayed on drawer close in the drawer dialog - X report (Current Shift Report) button on open drawer view - Report shows: sales summary, payment breakdown, discounts, cash accountability, adjustments Daily Rollup: - ReportService.getDailyReport() aggregates all sessions at a location for a date - New /reports/daily endpoint with locationId + date params - Frontend daily report page with date picker, location selector, session breakdown Critical Fix: - drawerSessionId is now populated on transactions when completing (was never set before) - This enables accurate per-drawer reporting and cash accountability Migration 0044: register table, drawer_session.register_id column Tests: 14 new (register CRUD, drawer report X/Z, drawerSessionId population, daily rollup, register-drawer link) Full suite: 367 passed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
472 lines
19 KiB
TypeScript
472 lines
19 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { usePOSStore } from '@/stores/pos.store'
|
|
import { posMutations, posKeys, drawerReportOptions, type DrawerSession } from '@/api/pos'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { ManagerOverrideDialog, requiresOverride } from './pos-manager-override'
|
|
|
|
interface POSDrawerDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
drawer: DrawerSession | null
|
|
}
|
|
|
|
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
|
|
const queryClient = useQueryClient()
|
|
const { locationId, registerId, setDrawerSession } = usePOSStore()
|
|
const isOpen = drawer?.status === 'open'
|
|
|
|
const [openingBalance, setOpeningBalance] = useState('200')
|
|
const [closingBalance, setClosingBalance] = useState('')
|
|
const [notes, setNotes] = useState('')
|
|
const [adjustView, setAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
|
|
const [adjAmount, setAdjAmount] = useState('')
|
|
const [adjReason, setAdjReason] = useState('')
|
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
|
const [pendingAdjustView, setPendingAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
|
|
const [showZReport, setShowZReport] = useState(false)
|
|
const [closedDrawerId, setClosedDrawerId] = useState<string | null>(null)
|
|
const [showXReport, setShowXReport] = useState(false)
|
|
|
|
// Z Report data (after close)
|
|
const { data: reportData } = useQuery(drawerReportOptions(closedDrawerId))
|
|
|
|
// X Report data (live, for open drawer)
|
|
const { data: xReportData } = useQuery(drawerReportOptions(showXReport ? drawer?.id ?? null : null))
|
|
|
|
// Fetch adjustments for open drawer
|
|
const { data: adjData } = useQuery({
|
|
queryKey: posKeys.drawerAdjustments(drawer?.id ?? ''),
|
|
queryFn: () => posMutations.getAdjustments(drawer!.id),
|
|
enabled: !!drawer?.id && isOpen,
|
|
})
|
|
const adjustments = adjData?.data ?? []
|
|
|
|
const openMutation = useMutation({
|
|
mutationFn: () =>
|
|
posMutations.openDrawer({
|
|
locationId: locationId ?? undefined,
|
|
registerId: registerId ?? undefined,
|
|
openingBalance: parseFloat(openingBalance) || 0,
|
|
}),
|
|
onSuccess: async (session) => {
|
|
setDrawerSession(session.id)
|
|
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
|
|
toast.success('Drawer opened')
|
|
onOpenChange(false)
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const closeMutation = useMutation({
|
|
mutationFn: () =>
|
|
posMutations.closeDrawer(drawer!.id, {
|
|
closingBalance: parseFloat(closingBalance) || 0,
|
|
notes: notes || undefined,
|
|
}),
|
|
onSuccess: async (session) => {
|
|
setDrawerSession(null)
|
|
const overShort = parseFloat(session.overShort ?? '0')
|
|
if (Math.abs(overShort) < 0.01) {
|
|
toast.success('Drawer closed - balanced')
|
|
} else {
|
|
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
|
|
}
|
|
// Show Z report
|
|
setClosedDrawerId(session.id)
|
|
setShowZReport(true)
|
|
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const adjustMutation = useMutation({
|
|
mutationFn: () =>
|
|
posMutations.addAdjustment(drawer!.id, {
|
|
type: adjustView!,
|
|
amount: parseFloat(adjAmount) || 0,
|
|
reason: adjReason,
|
|
}),
|
|
onSuccess: (adj) => {
|
|
queryClient.invalidateQueries({ queryKey: posKeys.drawerAdjustments(drawer!.id) })
|
|
toast.success(`${adj.type === 'cash_in' ? 'Cash added' : 'Cash removed'}: $${parseFloat(adj.amount).toFixed(2)}`)
|
|
setAdjustView(null)
|
|
setAdjAmount('')
|
|
setAdjReason('')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
// Z Report view (shown after drawer close)
|
|
if (showZReport && reportData) {
|
|
const r = reportData
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) { setShowZReport(false); setClosedDrawerId(null) } }}>
|
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Z Report — Drawer Closed</DialogTitle>
|
|
</DialogHeader>
|
|
<DrawerReportView report={r} />
|
|
<Button variant="outline" className="w-full" onClick={() => { setShowZReport(false); setClosedDrawerId(null); onOpenChange(false) }}>
|
|
Done
|
|
</Button>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// X Report view (mid-shift snapshot)
|
|
if (showXReport && xReportData) {
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>X Report — Current Shift</DialogTitle>
|
|
</DialogHeader>
|
|
<DrawerReportView report={xReportData} />
|
|
<Button variant="outline" className="w-full" onClick={() => setShowXReport(false)}>
|
|
Back
|
|
</Button>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Adjustment entry view
|
|
if (adjustView && isOpen) {
|
|
const isCashIn = adjustView === 'cash_in'
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{isCashIn ? 'Cash In' : 'Cash Out'}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Amount *</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0.01"
|
|
value={adjAmount}
|
|
onChange={(e) => setAdjAmount(e.target.value)}
|
|
placeholder="0.00"
|
|
className="h-11 text-lg"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Reason *</Label>
|
|
<Input
|
|
value={adjReason}
|
|
onChange={(e) => setAdjReason(e.target.value)}
|
|
placeholder={isCashIn ? 'e.g. Extra change' : 'e.g. Bank deposit'}
|
|
className="h-11"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
className="flex-1 h-12"
|
|
onClick={() => adjustMutation.mutate()}
|
|
disabled={!adjAmount || !adjReason || adjustMutation.isPending}
|
|
>
|
|
{adjustMutation.isPending ? 'Saving...' : isCashIn ? 'Add Cash' : 'Remove Cash'}
|
|
</Button>
|
|
<Button variant="outline" className="h-12" onClick={() => setAdjustView(null)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{isOpen ? 'Drawer' : 'Open Drawer'}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{isOpen ? (
|
|
<div className="space-y-4">
|
|
<div className="text-sm space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Opening Balance</span>
|
|
<span>${parseFloat(drawer!.openingBalance).toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Opened</span>
|
|
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cash In / Cash Out buttons */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="h-11 gap-2"
|
|
onClick={() => {
|
|
if (requiresOverride('cash_in_out')) {
|
|
setPendingAdjustView('cash_in')
|
|
setOverrideOpen(true)
|
|
} else {
|
|
setAdjustView('cash_in')
|
|
}
|
|
}}
|
|
>
|
|
<ArrowDownToLine className="h-4 w-4 text-green-600" />
|
|
Cash In
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-11 gap-2"
|
|
onClick={() => {
|
|
if (requiresOverride('cash_in_out')) {
|
|
setPendingAdjustView('cash_out')
|
|
setOverrideOpen(true)
|
|
} else {
|
|
setAdjustView('cash_out')
|
|
}
|
|
}}
|
|
>
|
|
<ArrowUpFromLine className="h-4 w-4 text-red-600" />
|
|
Cash Out
|
|
</Button>
|
|
</div>
|
|
|
|
{/* X Report button */}
|
|
<Button variant="outline" className="w-full h-10 gap-2 text-sm" onClick={() => setShowXReport(true)}>
|
|
Current Shift Report
|
|
</Button>
|
|
|
|
{/* Adjustment history */}
|
|
{adjustments.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-1">
|
|
<span className="text-xs font-medium text-muted-foreground">Adjustments</span>
|
|
{adjustments.map((adj) => (
|
|
<div key={adj.id} className="flex items-center justify-between text-sm py-1">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
|
|
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
|
|
</Badge>
|
|
<span className="text-muted-foreground truncate max-w-[140px]">{adj.reason}</span>
|
|
</div>
|
|
<span className={adj.type === 'cash_in' ? 'text-green-600' : 'text-red-600'}>
|
|
{adj.type === 'cash_in' ? '+' : '-'}${parseFloat(adj.amount).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* Close drawer */}
|
|
<div className="space-y-2">
|
|
<Label>Closing Balance *</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={closingBalance}
|
|
onChange={(e) => setClosingBalance(e.target.value)}
|
|
placeholder="Count the cash in the drawer"
|
|
className="h-11 text-lg"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Notes</Label>
|
|
<Input
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="End of shift notes"
|
|
className="h-11"
|
|
/>
|
|
</div>
|
|
<Button
|
|
className="w-full h-12"
|
|
onClick={() => closeMutation.mutate()}
|
|
disabled={!closingBalance || closeMutation.isPending}
|
|
>
|
|
{closeMutation.isPending ? 'Closing...' : 'Close Drawer'}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Opening Balance *</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={openingBalance}
|
|
onChange={(e) => setOpeningBalance(e.target.value)}
|
|
placeholder="Starting cash amount"
|
|
className="h-11 text-lg"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<Button
|
|
className="w-full h-12"
|
|
onClick={() => openMutation.mutate()}
|
|
disabled={!openingBalance || openMutation.isPending}
|
|
>
|
|
{openMutation.isPending ? 'Opening...' : 'Open Drawer'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<ManagerOverrideDialog
|
|
open={overrideOpen}
|
|
onOpenChange={setOverrideOpen}
|
|
action={pendingAdjustView === 'cash_in' ? 'Cash In' : 'Cash Out'}
|
|
onAuthorized={() => {
|
|
if (pendingAdjustView) setAdjustView(pendingAdjustView)
|
|
setPendingAdjustView(null)
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// --- Shared report view used by both X and Z reports ---
|
|
|
|
const PAYMENT_LABELS: Record<string, string> = {
|
|
cash: 'Cash',
|
|
card_present: 'Card (Present)',
|
|
card_keyed: 'Card (Keyed)',
|
|
check: 'Check',
|
|
account_charge: 'Account',
|
|
unknown: 'Other',
|
|
}
|
|
|
|
function DrawerReportView({ report }: { report: any }) {
|
|
const { session, sales, payments, discounts, cash, adjustments } = report
|
|
|
|
return (
|
|
<div className="space-y-4 text-sm">
|
|
{/* Session info */}
|
|
<div className="space-y-1">
|
|
{session.register && <div className="flex justify-between"><span className="text-muted-foreground">Register</span><span>{session.register.name}</span></div>}
|
|
{session.openedBy && <div className="flex justify-between"><span className="text-muted-foreground">Opened by</span><span>{session.openedBy.firstName} {session.openedBy.lastName}</span></div>}
|
|
<div className="flex justify-between"><span className="text-muted-foreground">Opened</span><span>{new Date(session.openedAt).toLocaleString()}</span></div>
|
|
{session.closedAt && (
|
|
<>
|
|
{session.closedBy && <div className="flex justify-between"><span className="text-muted-foreground">Closed by</span><span>{session.closedBy.firstName} {session.closedBy.lastName}</span></div>}
|
|
<div className="flex justify-between"><span className="text-muted-foreground">Closed</span><span>{new Date(session.closedAt).toLocaleString()}</span></div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Sales */}
|
|
<div>
|
|
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Sales</h4>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between"><span>Transactions</span><span>{sales.transactionCount}</span></div>
|
|
<div className="flex justify-between"><span>Gross Sales</span><span className="tabular-nums">${sales.grossSales.toFixed(2)}</span></div>
|
|
{sales.refundTotal > 0 && <div className="flex justify-between text-red-600"><span>Refunds</span><span className="tabular-nums">-${sales.refundTotal.toFixed(2)}</span></div>}
|
|
<div className="flex justify-between font-medium"><span>Net Sales</span><span className="tabular-nums">${sales.netSales.toFixed(2)}</span></div>
|
|
{sales.voidCount > 0 && <div className="flex justify-between text-muted-foreground"><span>Voided</span><span>{sales.voidCount}</span></div>}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Payment breakdown */}
|
|
<div>
|
|
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Payments</h4>
|
|
<div className="space-y-1">
|
|
{Object.entries(payments as Record<string, { count: number; total: number }>).map(([method, data]) => (
|
|
<div key={method} className="flex justify-between">
|
|
<span>{PAYMENT_LABELS[method] ?? method} ({data.count})</span>
|
|
<span className="tabular-nums">${data.total.toFixed(2)}</span>
|
|
</div>
|
|
))}
|
|
{Object.keys(payments).length === 0 && <p className="text-muted-foreground">No payments</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Discounts */}
|
|
{discounts.count > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div>
|
|
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Discounts</h4>
|
|
<div className="flex justify-between"><span>Total ({discounts.count})</span><span className="tabular-nums text-green-600">-${discounts.total.toFixed(2)}</span></div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* Cash accountability */}
|
|
<div>
|
|
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Cash</h4>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between"><span>Opening Balance</span><span className="tabular-nums">${cash.openingBalance.toFixed(2)}</span></div>
|
|
<div className="flex justify-between"><span>Cash Sales</span><span className="tabular-nums">${cash.cashSales.toFixed(2)}</span></div>
|
|
{cash.cashIn > 0 && <div className="flex justify-between text-green-600"><span>Cash In</span><span className="tabular-nums">+${cash.cashIn.toFixed(2)}</span></div>}
|
|
{cash.cashOut > 0 && <div className="flex justify-between text-red-600"><span>Cash Out</span><span className="tabular-nums">-${cash.cashOut.toFixed(2)}</span></div>}
|
|
<Separator />
|
|
<div className="flex justify-between font-medium"><span>Expected</span><span className="tabular-nums">${cash.expectedBalance.toFixed(2)}</span></div>
|
|
{cash.actualBalance !== null && (
|
|
<>
|
|
<div className="flex justify-between"><span>Actual Count</span><span className="tabular-nums">${cash.actualBalance.toFixed(2)}</span></div>
|
|
<div className={`flex justify-between font-bold ${cash.overShort === 0 ? 'text-green-600' : 'text-red-600'}`}>
|
|
<span>{cash.overShort! >= 0 ? 'Over' : 'Short'}</span>
|
|
<span className="tabular-nums">${Math.abs(cash.overShort!).toFixed(2)}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Adjustments */}
|
|
{adjustments.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div>
|
|
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Adjustments</h4>
|
|
<div className="space-y-1">
|
|
{adjustments.map((adj: any) => (
|
|
<div key={adj.id} className="flex items-center justify-between text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
|
|
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
|
|
</Badge>
|
|
<span className="text-muted-foreground truncate max-w-[150px]">{adj.reason}</span>
|
|
</div>
|
|
<span className="tabular-nums">${parseFloat(adj.amount).toFixed(2)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|