feat: named registers, X/Z reports, daily rollup, fix drawerSessionId

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>
This commit is contained in:
ryan
2026-04-05 02:21:55 +00:00
parent a29e924544
commit 663f00b099
17 changed files with 1062 additions and 6 deletions

View File

@@ -88,6 +88,15 @@ export interface Product {
isActive: boolean
}
export interface Register {
id: string
locationId: string
name: string
isActive: boolean
createdAt: string
updatedAt: string
}
// --- Query Keys ---
export interface DrawerAdjustment {
@@ -104,6 +113,9 @@ export const posKeys = {
transaction: (id: string) => ['pos', 'transaction', id] as const,
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
drawerAdjustments: (id: string) => ['pos', 'drawer-adjustments', id] as const,
drawerReport: (id: string) => ['pos', 'drawer-report', id] as const,
dailyReport: (locationId: string, date: string) => ['pos', 'daily-report', locationId, date] as const,
registers: (locationId: string) => ['pos', 'registers', locationId] as const,
products: (search: string) => ['pos', 'products', search] as const,
discounts: ['pos', 'discounts'] as const,
}
@@ -148,6 +160,30 @@ export function discountListOptions() {
})
}
export function registerListOptions(locationId: string | null) {
return queryOptions({
queryKey: posKeys.registers(locationId ?? ''),
queryFn: () => api.get<{ data: Register[] }>('/v1/registers/all', { locationId }),
enabled: !!locationId,
})
}
export function drawerReportOptions(drawerSessionId: string | null) {
return queryOptions({
queryKey: posKeys.drawerReport(drawerSessionId ?? ''),
queryFn: () => api.get<any>(`/v1/reports/drawer/${drawerSessionId}`),
enabled: !!drawerSessionId,
})
}
export function dailyReportOptions(locationId: string | null, date: string) {
return queryOptions({
queryKey: posKeys.dailyReport(locationId ?? '', date),
queryFn: () => api.get<any>('/v1/reports/daily', { locationId, date }),
enabled: !!locationId && !!date,
})
}
// --- Mutations ---
export const posMutations = {
@@ -169,7 +205,7 @@ export const posMutations = {
void: (txnId: string) =>
api.post<Transaction>(`/v1/transactions/${txnId}/void`, {}),
openDrawer: (data: { locationId?: string; openingBalance: number }) =>
openDrawer: (data: { locationId?: string; registerId?: string; openingBalance: number }) =>
api.post<DrawerSession>('/v1/drawer/open', data),
closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) =>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type DrawerSession } from '@/api/pos'
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'
@@ -20,7 +20,7 @@ interface POSDrawerDialogProps {
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
const queryClient = useQueryClient()
const { locationId, setDrawerSession } = usePOSStore()
const { locationId, registerId, setDrawerSession } = usePOSStore()
const isOpen = drawer?.status === 'open'
const [openingBalance, setOpeningBalance] = useState('200')
@@ -31,6 +31,15 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
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({
@@ -44,6 +53,7 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
mutationFn: () =>
posMutations.openDrawer({
locationId: locationId ?? undefined,
registerId: registerId ?? undefined,
openingBalance: parseFloat(openingBalance) || 0,
}),
onSuccess: async (session) => {
@@ -69,7 +79,9 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
} else {
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
}
onOpenChange(false)
// Show Z report
setClosedDrawerId(session.id)
setShowZReport(true)
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
},
onError: (err) => toast.error(err.message),
@@ -92,6 +104,45 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
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'
@@ -199,6 +250,11 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
</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 && (
<>
@@ -293,3 +349,123 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
</>
)
}
// --- 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>
)
}

View File

@@ -0,0 +1,180 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { dailyReportOptions } from '@/api/pos'
import { api } from '@/lib/api-client'
import { queryOptions } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
interface Location {
id: string
name: string
}
function locationsOptions() {
return queryOptions({
queryKey: ['locations'],
queryFn: () => api.get<{ data: Location[] }>('/v1/locations'),
})
}
export const Route = createFileRoute('/_authenticated/reports/daily')({
component: DailyReportPage,
})
const PAYMENT_LABELS: Record<string, string> = {
cash: 'Cash',
card_present: 'Card (Present)',
card_keyed: 'Card (Keyed)',
check: 'Check',
account_charge: 'Account',
}
function DailyReportPage() {
const today = new Date().toISOString().slice(0, 10)
const [date, setDate] = useState(today)
const [locationId, setLocationId] = useState<string | null>(null)
const { data: locationsData } = useQuery(locationsOptions())
const locations = locationsData?.data ?? []
// Auto-select first location
if (!locationId && locations.length > 0) {
setLocationId(locations[0].id)
}
const { data: report, isLoading } = useQuery(dailyReportOptions(locationId, date))
return (
<div className="space-y-6 max-w-3xl">
<h1 className="text-2xl font-bold">Daily Report</h1>
<div className="flex gap-4">
<div className="space-y-1">
<Label className="text-xs">Date</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="w-44" />
</div>
<div className="space-y-1">
<Label className="text-xs">Location</Label>
<Select value={locationId ?? ''} onValueChange={setLocationId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select location" />
</SelectTrigger>
<SelectContent>
{locations.map((loc) => (
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : !report ? (
<p className="text-muted-foreground">Select a location and date to view the report.</p>
) : (
<div className="space-y-4">
{/* Sessions */}
<Card>
<CardHeader>
<CardTitle className="text-base">Drawer Sessions ({report.sessions.length})</CardTitle>
</CardHeader>
<CardContent>
{report.sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">No drawer sessions on this date.</p>
) : (
<div className="space-y-2">
{report.sessions.map((s: any) => (
<div key={s.id} className="flex items-center justify-between p-2 rounded border text-sm">
<div>
<span className="font-medium">{s.register?.name ?? 'Unassigned'}</span>
<span className="text-muted-foreground ml-2">
{new Date(s.openedAt).toLocaleTimeString()} {s.closedAt ? new Date(s.closedAt).toLocaleTimeString() : 'Open'}
</span>
{s.openedBy && <span className="text-muted-foreground ml-2">({s.openedBy.firstName})</span>}
</div>
<div className="flex items-center gap-3">
<span className="tabular-nums text-sm">${s.grossSales.toFixed(2)}</span>
{s.overShort !== null && (
<Badge variant={s.overShort === 0 ? 'default' : 'destructive'} className="text-xs">
{s.overShort === 0 ? 'Balanced' : `${s.overShort > 0 ? '+' : ''}$${s.overShort.toFixed(2)}`}
</Badge>
)}
{s.status === 'open' && <Badge variant="outline" className="text-xs">Open</Badge>}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Sales Summary */}
<Card>
<CardHeader><CardTitle className="text-base">Sales</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between"><span>Transactions</span><span>{report.sales.transactionCount}</span></div>
<div className="flex justify-between"><span>Gross Sales</span><span className="tabular-nums">${report.sales.grossSales.toFixed(2)}</span></div>
{report.sales.refundTotal > 0 && <div className="flex justify-between text-red-600"><span>Refunds</span><span className="tabular-nums">-${report.sales.refundTotal.toFixed(2)}</span></div>}
<Separator />
<div className="flex justify-between font-semibold"><span>Net Sales</span><span className="tabular-nums">${report.sales.netSales.toFixed(2)}</span></div>
{report.sales.voidCount > 0 && <div className="flex justify-between text-muted-foreground"><span>Voided</span><span>{report.sales.voidCount}</span></div>}
</CardContent>
</Card>
{/* Payment Breakdown */}
<Card>
<CardHeader><CardTitle className="text-base">Payments</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
{Object.entries(report.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(report.payments).length === 0 && <p className="text-muted-foreground">No payments</p>}
</CardContent>
</Card>
{/* Discounts */}
{report.discounts.count > 0 && (
<Card>
<CardHeader><CardTitle className="text-base">Discounts</CardTitle></CardHeader>
<CardContent className="text-sm">
<div className="flex justify-between"><span>Total ({report.discounts.count} transactions)</span><span className="tabular-nums text-green-600">-${report.discounts.total.toFixed(2)}</span></div>
</CardContent>
</Card>
)}
{/* Cash Summary */}
<Card>
<CardHeader><CardTitle className="text-base">Cash</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between"><span>Total Opening</span><span className="tabular-nums">${report.cash.totalOpening.toFixed(2)}</span></div>
<div className="flex justify-between"><span>Cash Sales</span><span className="tabular-nums">${report.cash.totalCashSales.toFixed(2)}</span></div>
{report.cash.totalCashIn > 0 && <div className="flex justify-between text-green-600"><span>Cash In</span><span className="tabular-nums">+${report.cash.totalCashIn.toFixed(2)}</span></div>}
{report.cash.totalCashOut > 0 && <div className="flex justify-between text-red-600"><span>Cash Out</span><span className="tabular-nums">-${report.cash.totalCashOut.toFixed(2)}</span></div>}
<Separator />
<div className="flex justify-between font-medium"><span>Expected Total</span><span className="tabular-nums">${report.cash.totalExpected.toFixed(2)}</span></div>
<div className="flex justify-between"><span>Actual Total</span><span className="tabular-nums">${report.cash.totalActual.toFixed(2)}</span></div>
<div className={`flex justify-between font-bold ${report.cash.totalOverShort === 0 ? 'text-green-600' : 'text-red-600'}`}>
<span>{report.cash.totalOverShort >= 0 ? 'Over' : 'Short'}</span>
<span className="tabular-nums">${Math.abs(report.cash.totalOverShort).toFixed(2)}</span>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -13,6 +13,7 @@ type ReceiptFormat = 'thermal' | 'full'
interface POSState {
currentTransactionId: string | null
locationId: string | null
registerId: string | null
drawerSessionId: string | null
locked: boolean
cashier: POSUser | null
@@ -25,6 +26,7 @@ interface POSState {
receiptFormat: ReceiptFormat
setTransaction: (id: string | null) => void
setLocation: (id: string) => void
setRegister: (id: string | null) => void
setDrawerSession: (id: string | null) => void
unlock: (user: POSUser, token: string) => void
lock: () => void
@@ -45,7 +47,8 @@ function getStoredReceiptFormat(): ReceiptFormat {
export const usePOSStore = create<POSState>((set) => ({
currentTransactionId: null,
locationId: null,
drawerSessionId: null,
registerId: localStorage.getItem('pos_register_id') ?? null,
drawerSessionId: localStorage.getItem('pos_drawer_session_id') ?? null,
locked: true,
cashier: null,
token: null,
@@ -57,7 +60,8 @@ export const usePOSStore = create<POSState>((set) => ({
receiptFormat: getStoredReceiptFormat(),
setTransaction: (id) => set({ currentTransactionId: id }),
setLocation: (id) => set({ locationId: id }),
setDrawerSession: (id) => set({ drawerSessionId: id }),
setRegister: (id) => { if (id) localStorage.setItem('pos_register_id', id); else localStorage.removeItem('pos_register_id'); set({ registerId: id }) },
setDrawerSession: (id) => { if (id) localStorage.setItem('pos_drawer_session_id', id); else localStorage.removeItem('pos_drawer_session_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() }),