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:
180
packages/admin/src/routes/_authenticated/reports/daily.tsx
Normal file
180
packages/admin/src/routes/_authenticated/reports/daily.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user