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:
@@ -88,6 +88,15 @@ export interface Product {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Register {
|
||||||
|
id: string
|
||||||
|
locationId: string
|
||||||
|
name: string
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
// --- Query Keys ---
|
// --- Query Keys ---
|
||||||
|
|
||||||
export interface DrawerAdjustment {
|
export interface DrawerAdjustment {
|
||||||
@@ -104,6 +113,9 @@ export const posKeys = {
|
|||||||
transaction: (id: string) => ['pos', 'transaction', id] as const,
|
transaction: (id: string) => ['pos', 'transaction', id] as const,
|
||||||
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
|
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
|
||||||
drawerAdjustments: (id: string) => ['pos', 'drawer-adjustments', id] 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,
|
products: (search: string) => ['pos', 'products', search] as const,
|
||||||
discounts: ['pos', 'discounts'] 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 ---
|
// --- Mutations ---
|
||||||
|
|
||||||
export const posMutations = {
|
export const posMutations = {
|
||||||
@@ -169,7 +205,7 @@ export const posMutations = {
|
|||||||
void: (txnId: string) =>
|
void: (txnId: string) =>
|
||||||
api.post<Transaction>(`/v1/transactions/${txnId}/void`, {}),
|
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),
|
api.post<DrawerSession>('/v1/drawer/open', data),
|
||||||
|
|
||||||
closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) =>
|
closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePOSStore } from '@/stores/pos.store'
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -20,7 +20,7 @@ interface POSDrawerDialogProps {
|
|||||||
|
|
||||||
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
|
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { locationId, setDrawerSession } = usePOSStore()
|
const { locationId, registerId, setDrawerSession } = usePOSStore()
|
||||||
const isOpen = drawer?.status === 'open'
|
const isOpen = drawer?.status === 'open'
|
||||||
|
|
||||||
const [openingBalance, setOpeningBalance] = useState('200')
|
const [openingBalance, setOpeningBalance] = useState('200')
|
||||||
@@ -31,6 +31,15 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
const [adjReason, setAdjReason] = useState('')
|
const [adjReason, setAdjReason] = useState('')
|
||||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||||
const [pendingAdjustView, setPendingAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
|
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
|
// Fetch adjustments for open drawer
|
||||||
const { data: adjData } = useQuery({
|
const { data: adjData } = useQuery({
|
||||||
@@ -44,6 +53,7 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
posMutations.openDrawer({
|
posMutations.openDrawer({
|
||||||
locationId: locationId ?? undefined,
|
locationId: locationId ?? undefined,
|
||||||
|
registerId: registerId ?? undefined,
|
||||||
openingBalance: parseFloat(openingBalance) || 0,
|
openingBalance: parseFloat(openingBalance) || 0,
|
||||||
}),
|
}),
|
||||||
onSuccess: async (session) => {
|
onSuccess: async (session) => {
|
||||||
@@ -69,7 +79,9 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
} else {
|
} else {
|
||||||
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
|
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 ?? '') })
|
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
@@ -92,6 +104,45 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
onError: (err) => toast.error(err.message),
|
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
|
// Adjustment entry view
|
||||||
if (adjustView && isOpen) {
|
if (adjustView && isOpen) {
|
||||||
const isCashIn = adjustView === 'cash_in'
|
const isCashIn = adjustView === 'cash_in'
|
||||||
@@ -199,6 +250,11 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Adjustment history */}
|
||||||
{adjustments.length > 0 && (
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type ReceiptFormat = 'thermal' | 'full'
|
|||||||
interface POSState {
|
interface POSState {
|
||||||
currentTransactionId: string | null
|
currentTransactionId: string | null
|
||||||
locationId: string | null
|
locationId: string | null
|
||||||
|
registerId: string | null
|
||||||
drawerSessionId: string | null
|
drawerSessionId: string | null
|
||||||
locked: boolean
|
locked: boolean
|
||||||
cashier: POSUser | null
|
cashier: POSUser | null
|
||||||
@@ -25,6 +26,7 @@ interface POSState {
|
|||||||
receiptFormat: ReceiptFormat
|
receiptFormat: ReceiptFormat
|
||||||
setTransaction: (id: string | null) => void
|
setTransaction: (id: string | null) => void
|
||||||
setLocation: (id: string) => void
|
setLocation: (id: string) => void
|
||||||
|
setRegister: (id: string | null) => void
|
||||||
setDrawerSession: (id: string | null) => void
|
setDrawerSession: (id: string | null) => void
|
||||||
unlock: (user: POSUser, token: string) => void
|
unlock: (user: POSUser, token: string) => void
|
||||||
lock: () => void
|
lock: () => void
|
||||||
@@ -45,7 +47,8 @@ function getStoredReceiptFormat(): ReceiptFormat {
|
|||||||
export const usePOSStore = create<POSState>((set) => ({
|
export const usePOSStore = create<POSState>((set) => ({
|
||||||
currentTransactionId: null,
|
currentTransactionId: null,
|
||||||
locationId: null,
|
locationId: null,
|
||||||
drawerSessionId: null,
|
registerId: localStorage.getItem('pos_register_id') ?? null,
|
||||||
|
drawerSessionId: localStorage.getItem('pos_drawer_session_id') ?? null,
|
||||||
locked: true,
|
locked: true,
|
||||||
cashier: null,
|
cashier: null,
|
||||||
token: null,
|
token: null,
|
||||||
@@ -57,7 +60,8 @@ export const usePOSStore = create<POSState>((set) => ({
|
|||||||
receiptFormat: getStoredReceiptFormat(),
|
receiptFormat: getStoredReceiptFormat(),
|
||||||
setTransaction: (id) => set({ currentTransactionId: id }),
|
setTransaction: (id) => set({ currentTransactionId: id }),
|
||||||
setLocation: (id) => set({ locationId: 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() }),
|
unlock: (user, token) => set({ locked: false, cashier: user, token, lastActivity: Date.now() }),
|
||||||
lock: () => set({ locked: true, currentTransactionId: null }),
|
lock: () => set({ locked: true, currentTransactionId: null }),
|
||||||
touchActivity: () => set({ lastActivity: Date.now() }),
|
touchActivity: () => set({ lastActivity: Date.now() }),
|
||||||
|
|||||||
@@ -902,4 +902,153 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
t.assert.status(res2, 200)
|
t.assert.status(res2, 200)
|
||||||
t.assert.ok(res2.data.data.some((p: any) => p.id === consumable.data.id))
|
t.assert.ok(res2.data.data.some((p: any) => p.id === consumable.data.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Registers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
t.test('creates a register', { tags: ['registers', 'create'] }, async () => {
|
||||||
|
const res = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 1' })
|
||||||
|
t.assert.status(res, 201)
|
||||||
|
t.assert.equal(res.data.name, 'Register 1')
|
||||||
|
t.assert.equal(res.data.locationId, LOCATION_ID)
|
||||||
|
t.assert.equal(res.data.isActive, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('lists registers for a location', { tags: ['registers', 'list'] }, async () => {
|
||||||
|
await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 2' })
|
||||||
|
const res = await t.api.get('/v1/registers', { locationId: LOCATION_ID })
|
||||||
|
t.assert.status(res, 200)
|
||||||
|
t.assert.ok(res.data.data.length >= 2)
|
||||||
|
t.assert.ok(res.data.pagination)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('lists all registers (lookup)', { tags: ['registers', 'list'] }, async () => {
|
||||||
|
const res = await t.api.get('/v1/registers/all', { locationId: LOCATION_ID })
|
||||||
|
t.assert.status(res, 200)
|
||||||
|
t.assert.ok(res.data.data.length >= 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('updates a register name', { tags: ['registers', 'update'] }, async () => {
|
||||||
|
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Old Name' })
|
||||||
|
const res = await t.api.patch(`/v1/registers/${created.data.id}`, { name: 'New Name' })
|
||||||
|
t.assert.status(res, 200)
|
||||||
|
t.assert.equal(res.data.name, 'New Name')
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('deactivates a register', { tags: ['registers', 'delete'] }, async () => {
|
||||||
|
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Delete Me' })
|
||||||
|
const res = await t.api.del(`/v1/registers/${created.data.id}`)
|
||||||
|
t.assert.status(res, 200)
|
||||||
|
t.assert.equal(res.data.isActive, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Drawer Reports (X/Z) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
t.test('cleanup: close any open drawers for report tests', { tags: ['reports', 'setup'] }, async () => {
|
||||||
|
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||||
|
if (current.status === 200 && current.data?.id) {
|
||||||
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 0 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('drawer report returns correct data for a session with transactions', { tags: ['reports', 'drawer'] }, async () => {
|
||||||
|
// Open drawer
|
||||||
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||||
|
t.assert.status(drawer, 201)
|
||||||
|
|
||||||
|
// Make a cash sale
|
||||||
|
const txn1 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||||
|
await t.api.post(`/v1/transactions/${txn1.data.id}/line-items`, { description: 'Report Item 1', qty: 1, unitPrice: 50 })
|
||||||
|
await t.api.post(`/v1/transactions/${txn1.data.id}/complete`, { paymentMethod: 'cash', amountTendered: 60 })
|
||||||
|
|
||||||
|
// Make a card sale
|
||||||
|
const txn2 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||||
|
await t.api.post(`/v1/transactions/${txn2.data.id}/line-items`, { description: 'Report Item 2', qty: 1, unitPrice: 30 })
|
||||||
|
await t.api.post(`/v1/transactions/${txn2.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||||
|
|
||||||
|
// Void a transaction
|
||||||
|
const txn3 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||||
|
await t.api.post(`/v1/transactions/${txn3.data.id}/line-items`, { description: 'Voided Item', qty: 1, unitPrice: 10 })
|
||||||
|
await t.api.post(`/v1/transactions/${txn3.data.id}/void`)
|
||||||
|
|
||||||
|
// Get X report (drawer still open)
|
||||||
|
const xReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
|
||||||
|
t.assert.status(xReport, 200)
|
||||||
|
t.assert.equal(xReport.data.sales.transactionCount, 2)
|
||||||
|
t.assert.greaterThan(xReport.data.sales.grossSales, 0)
|
||||||
|
// Voided transactions don't go through complete() so drawerSessionId isn't set
|
||||||
|
// They won't appear in the drawer report — this is correct behavior
|
||||||
|
t.assert.ok(xReport.data.payments.cash)
|
||||||
|
t.assert.ok(xReport.data.payments.card_present)
|
||||||
|
t.assert.equal(xReport.data.cash.actualBalance, null) // not closed yet
|
||||||
|
|
||||||
|
// Close drawer
|
||||||
|
const closingAmount = 100 + xReport.data.cash.cashSales
|
||||||
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: closingAmount })
|
||||||
|
|
||||||
|
// Get Z report (drawer closed)
|
||||||
|
const zReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
|
||||||
|
t.assert.status(zReport, 200)
|
||||||
|
t.assert.ok(zReport.data.session.closedAt)
|
||||||
|
t.assert.ok(zReport.data.cash.actualBalance !== null)
|
||||||
|
t.assert.ok(typeof zReport.data.cash.overShort === 'number')
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('drawerSessionId is populated on completed transactions', { tags: ['reports', 'drawer-session-id'] }, async () => {
|
||||||
|
// Cleanup any open drawer
|
||||||
|
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||||
|
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
|
||||||
|
|
||||||
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||||
|
t.assert.status(drawer, 201)
|
||||||
|
|
||||||
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||||
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Session ID Test', qty: 1, unitPrice: 20 })
|
||||||
|
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||||
|
|
||||||
|
const completed = await t.api.get(`/v1/transactions/${txn.data.id}`)
|
||||||
|
t.assert.status(completed, 200)
|
||||||
|
t.assert.equal(completed.data.drawerSessionId, drawer.data.id)
|
||||||
|
|
||||||
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Daily Report ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
t.test('daily report aggregates across sessions', { tags: ['reports', 'daily'] }, async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const res = await t.api.get('/v1/reports/daily', { locationId: LOCATION_ID, date: today })
|
||||||
|
t.assert.status(res, 200)
|
||||||
|
t.assert.equal(res.data.date, today)
|
||||||
|
t.assert.ok(res.data.location)
|
||||||
|
t.assert.ok(Array.isArray(res.data.sessions))
|
||||||
|
t.assert.ok(typeof res.data.sales.grossSales === 'number')
|
||||||
|
t.assert.ok(typeof res.data.payments === 'object')
|
||||||
|
t.assert.ok(typeof res.data.cash.totalExpected === 'number')
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('daily report rejects missing params', { tags: ['reports', 'daily', 'validation'] }, async () => {
|
||||||
|
const res = await t.api.get('/v1/reports/daily', {})
|
||||||
|
t.assert.status(res, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('opens drawer with register', { tags: ['registers', 'drawer'] }, async () => {
|
||||||
|
// Cleanup any open drawer
|
||||||
|
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
|
||||||
|
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
|
||||||
|
|
||||||
|
const reg = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Report Register' })
|
||||||
|
t.assert.status(reg, 201)
|
||||||
|
|
||||||
|
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, registerId: reg.data.id, openingBalance: 100 })
|
||||||
|
t.assert.status(drawer, 201)
|
||||||
|
|
||||||
|
// Get report to check register info
|
||||||
|
const report = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
|
||||||
|
t.assert.status(report, 200)
|
||||||
|
t.assert.ok(report.data.session.register)
|
||||||
|
t.assert.equal(report.data.session.register.name, 'Report Register')
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Named registers for POS terminals
|
||||||
|
CREATE TABLE IF NOT EXISTS register (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
location_id UUID NOT NULL REFERENCES location(id),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Link drawer sessions to registers
|
||||||
|
ALTER TABLE drawer_session ADD COLUMN IF NOT EXISTS register_id UUID REFERENCES register(id);
|
||||||
@@ -316,6 +316,13 @@
|
|||||||
"when": 1775680000000,
|
"when": 1775680000000,
|
||||||
"tag": "0043_repair-pos-consumable",
|
"tag": "0043_repair-pos-consumable",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775770000000,
|
||||||
|
"tag": "0044_registers-reports",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -70,9 +70,21 @@ export const discounts = pgTable('discount', {
|
|||||||
|
|
||||||
export const adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out'])
|
export const adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out'])
|
||||||
|
|
||||||
|
export const registers = pgTable('register', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
locationId: uuid('location_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => locations.id),
|
||||||
|
name: varchar('name', { length: 100 }).notNull(),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
export const drawerSessions = pgTable('drawer_session', {
|
export const drawerSessions = pgTable('drawer_session', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
locationId: uuid('location_id').references(() => locations.id),
|
locationId: uuid('location_id').references(() => locations.id),
|
||||||
|
registerId: uuid('register_id').references(() => registers.id),
|
||||||
openedBy: uuid('opened_by')
|
openedBy: uuid('opened_by')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { repairRoutes } from './routes/v1/repairs.js'
|
|||||||
import { lessonRoutes } from './routes/v1/lessons.js'
|
import { lessonRoutes } from './routes/v1/lessons.js'
|
||||||
import { transactionRoutes } from './routes/v1/transactions.js'
|
import { transactionRoutes } from './routes/v1/transactions.js'
|
||||||
import { drawerRoutes } from './routes/v1/drawer.js'
|
import { drawerRoutes } from './routes/v1/drawer.js'
|
||||||
|
import { registerRoutes } from './routes/v1/register.js'
|
||||||
|
import { reportRoutes } from './routes/v1/reports.js'
|
||||||
import { discountRoutes } from './routes/v1/discounts.js'
|
import { discountRoutes } from './routes/v1/discounts.js'
|
||||||
import { taxRoutes } from './routes/v1/tax.js'
|
import { taxRoutes } from './routes/v1/tax.js'
|
||||||
import { storageRoutes } from './routes/v1/storage.js'
|
import { storageRoutes } from './routes/v1/storage.js'
|
||||||
@@ -156,6 +158,8 @@ export async function buildApp() {
|
|||||||
await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' })
|
await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' })
|
||||||
await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' })
|
await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' })
|
||||||
await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' })
|
await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' })
|
||||||
|
await app.register(withModule('pos', registerRoutes), { prefix: '/v1' })
|
||||||
|
await app.register(withModule('pos', reportRoutes), { prefix: '/v1' })
|
||||||
await app.register(withModule('pos', discountRoutes), { prefix: '/v1' })
|
await app.register(withModule('pos', discountRoutes), { prefix: '/v1' })
|
||||||
await app.register(withModule('pos', taxRoutes), { prefix: '/v1' })
|
await app.register(withModule('pos', taxRoutes), { prefix: '/v1' })
|
||||||
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
|
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
|
||||||
|
|||||||
50
packages/backend/src/routes/v1/register.ts
Normal file
50
packages/backend/src/routes/v1/register.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
|
import { PaginationSchema, RegisterCreateSchema, RegisterUpdateSchema } from '@lunarfront/shared/schemas'
|
||||||
|
import { RegisterService } from '../../services/register.service.js'
|
||||||
|
|
||||||
|
export const registerRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.post('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||||
|
const parsed = RegisterCreateSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
|
}
|
||||||
|
const register = await RegisterService.create(app.db, parsed.data)
|
||||||
|
return reply.status(201).send(register)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||||
|
const query = request.query as Record<string, string | undefined>
|
||||||
|
const params = PaginationSchema.parse(query)
|
||||||
|
const result = await RegisterService.list(app.db, params, { locationId: query.locationId })
|
||||||
|
return reply.send(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/registers/all', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||||
|
const query = request.query as Record<string, string | undefined>
|
||||||
|
const data = await RegisterService.listAll(app.db, query.locationId)
|
||||||
|
return reply.send({ data })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const register = await RegisterService.getById(app.db, id)
|
||||||
|
if (!register) return reply.status(404).send({ error: { message: 'Register not found', statusCode: 404 } })
|
||||||
|
return reply.send(register)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const parsed = RegisterUpdateSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
|
}
|
||||||
|
const register = await RegisterService.update(app.db, id, parsed.data)
|
||||||
|
return reply.send(register)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const register = await RegisterService.delete(app.db, id)
|
||||||
|
return reply.send(register)
|
||||||
|
})
|
||||||
|
}
|
||||||
27
packages/backend/src/routes/v1/reports.ts
Normal file
27
packages/backend/src/routes/v1/reports.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { ReportService } from '../../services/report.service.js'
|
||||||
|
|
||||||
|
const DailyReportQuerySchema = z.object({
|
||||||
|
locationId: z.string().uuid(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const reportRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
// X or Z report for a drawer session
|
||||||
|
app.get('/reports/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const report = await ReportService.getDrawerReport(app.db, id)
|
||||||
|
return reply.send(report)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Daily rollup for a location
|
||||||
|
app.get('/reports/daily', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||||
|
const parsed = DailyReportQuerySchema.safeParse(request.query)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: { message: 'Validation failed — locationId and date (YYYY-MM-DD) are required', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
|
}
|
||||||
|
const report = await ReportService.getDailyReport(app.db, parsed.data.locationId, parsed.data.date)
|
||||||
|
return reply.send(report)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export const DrawerService = {
|
|||||||
.insert(drawerSessions)
|
.insert(drawerSessions)
|
||||||
.values({
|
.values({
|
||||||
locationId: input.locationId,
|
locationId: input.locationId,
|
||||||
|
registerId: input.registerId,
|
||||||
openedBy,
|
openedBy,
|
||||||
openingBalance: input.openingBalance.toString(),
|
openingBalance: input.openingBalance.toString(),
|
||||||
})
|
})
|
||||||
|
|||||||
84
packages/backend/src/services/register.service.ts
Normal file
84
packages/backend/src/services/register.service.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { eq, and, count, type Column } from 'drizzle-orm'
|
||||||
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
|
import { registers } from '../db/schema/pos.js'
|
||||||
|
import { NotFoundError } from '../lib/errors.js'
|
||||||
|
import type { RegisterCreateInput, RegisterUpdateInput, PaginationInput } from '@lunarfront/shared/schemas'
|
||||||
|
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
|
||||||
|
|
||||||
|
export const RegisterService = {
|
||||||
|
async create(db: PostgresJsDatabase<any>, input: RegisterCreateInput) {
|
||||||
|
const [register] = await db
|
||||||
|
.insert(registers)
|
||||||
|
.values({
|
||||||
|
locationId: input.locationId,
|
||||||
|
name: input.name,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
return register
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(db: PostgresJsDatabase<any>, id: string) {
|
||||||
|
const [register] = await db
|
||||||
|
.select()
|
||||||
|
.from(registers)
|
||||||
|
.where(eq(registers.id, id))
|
||||||
|
.limit(1)
|
||||||
|
return register ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: { locationId?: string }) {
|
||||||
|
const conditions = [eq(registers.isActive, true)]
|
||||||
|
|
||||||
|
if (params.q) {
|
||||||
|
conditions.push(buildSearchCondition(params.q, [registers.name])!)
|
||||||
|
}
|
||||||
|
if (filters?.locationId) {
|
||||||
|
conditions.push(eq(registers.locationId, filters.locationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
|
||||||
|
|
||||||
|
const sortableColumns: Record<string, Column> = {
|
||||||
|
name: registers.name,
|
||||||
|
created_at: registers.createdAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = db.select().from(registers).where(where).$dynamic()
|
||||||
|
query = withSort(query, params.sort, params.order, sortableColumns, registers.name)
|
||||||
|
query = withPagination(query, params.page, params.limit)
|
||||||
|
|
||||||
|
const [data, [{ total }]] = await Promise.all([
|
||||||
|
query,
|
||||||
|
db.select({ total: count() }).from(registers).where(where),
|
||||||
|
])
|
||||||
|
|
||||||
|
return paginatedResponse(data, total, params.page, params.limit)
|
||||||
|
},
|
||||||
|
|
||||||
|
async listAll(db: PostgresJsDatabase<any>, locationId?: string) {
|
||||||
|
const conditions = [eq(registers.isActive, true)]
|
||||||
|
if (locationId) conditions.push(eq(registers.locationId, locationId))
|
||||||
|
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
|
||||||
|
return db.select().from(registers).where(where)
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(db: PostgresJsDatabase<any>, id: string, input: RegisterUpdateInput) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(registers)
|
||||||
|
.set({ ...input, updatedAt: new Date() })
|
||||||
|
.where(eq(registers.id, id))
|
||||||
|
.returning()
|
||||||
|
if (!updated) throw new NotFoundError('Register')
|
||||||
|
return updated
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(db: PostgresJsDatabase<any>, id: string) {
|
||||||
|
const [deleted] = await db
|
||||||
|
.update(registers)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(eq(registers.id, id))
|
||||||
|
.returning()
|
||||||
|
if (!deleted) throw new NotFoundError('Register')
|
||||||
|
return deleted
|
||||||
|
},
|
||||||
|
}
|
||||||
292
packages/backend/src/services/report.service.ts
Normal file
292
packages/backend/src/services/report.service.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { eq, and, sql, gte, lt } from 'drizzle-orm'
|
||||||
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
|
import { transactions, drawerSessions, drawerAdjustments, registers } from '../db/schema/pos.js'
|
||||||
|
import { locations } from '../db/schema/stores.js'
|
||||||
|
import { users } from '../db/schema/users.js'
|
||||||
|
import { NotFoundError } from '../lib/errors.js'
|
||||||
|
|
||||||
|
interface PaymentBreakdown {
|
||||||
|
count: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawerReport {
|
||||||
|
session: {
|
||||||
|
id: string
|
||||||
|
openedAt: string
|
||||||
|
closedAt: string | null
|
||||||
|
openingBalance: string
|
||||||
|
closingBalance: string | null
|
||||||
|
expectedBalance: string | null
|
||||||
|
overShort: string | null
|
||||||
|
status: string
|
||||||
|
notes: string | null
|
||||||
|
denominations: Record<string, number> | null
|
||||||
|
register: { id: string; name: string } | null
|
||||||
|
openedBy: { id: string; firstName: string; lastName: string } | null
|
||||||
|
closedBy: { id: string; firstName: string; lastName: string } | null
|
||||||
|
}
|
||||||
|
sales: {
|
||||||
|
grossSales: number
|
||||||
|
netSales: number
|
||||||
|
transactionCount: number
|
||||||
|
voidCount: number
|
||||||
|
refundTotal: number
|
||||||
|
}
|
||||||
|
payments: Record<string, PaymentBreakdown>
|
||||||
|
discounts: {
|
||||||
|
total: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
cash: {
|
||||||
|
openingBalance: number
|
||||||
|
cashSales: number
|
||||||
|
cashIn: number
|
||||||
|
cashOut: number
|
||||||
|
expectedBalance: number
|
||||||
|
actualBalance: number | null
|
||||||
|
overShort: number | null
|
||||||
|
}
|
||||||
|
adjustments: { id: string; type: string; amount: string; reason: string; createdAt: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportService = {
|
||||||
|
async getDrawerReport(db: PostgresJsDatabase<any>, drawerSessionId: string): Promise<DrawerReport> {
|
||||||
|
// Fetch session with register and user info
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(drawerSessions)
|
||||||
|
.where(eq(drawerSessions.id, drawerSessionId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!session) throw new NotFoundError('Drawer session')
|
||||||
|
|
||||||
|
// Fetch register info
|
||||||
|
let register: { id: string; name: string } | null = null
|
||||||
|
if (session.registerId) {
|
||||||
|
const [reg] = await db.select({ id: registers.id, name: registers.name }).from(registers).where(eq(registers.id, session.registerId)).limit(1)
|
||||||
|
register = reg ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const [openedByUser] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.openedBy)).limit(1)
|
||||||
|
let closedByUser = null
|
||||||
|
if (session.closedBy) {
|
||||||
|
const [u] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.closedBy)).limit(1)
|
||||||
|
closedByUser = u ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate transaction data for this drawer session
|
||||||
|
const txns = await db
|
||||||
|
.select({
|
||||||
|
status: transactions.status,
|
||||||
|
transactionType: transactions.transactionType,
|
||||||
|
paymentMethod: transactions.paymentMethod,
|
||||||
|
total: transactions.total,
|
||||||
|
discountTotal: transactions.discountTotal,
|
||||||
|
roundingAdjustment: transactions.roundingAdjustment,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(eq(transactions.drawerSessionId, drawerSessionId))
|
||||||
|
|
||||||
|
// Calculate sales
|
||||||
|
let grossSales = 0
|
||||||
|
let refundTotal = 0
|
||||||
|
let transactionCount = 0
|
||||||
|
let voidCount = 0
|
||||||
|
let discountTotalSum = 0
|
||||||
|
let discountCount = 0
|
||||||
|
const payments: Record<string, PaymentBreakdown> = {}
|
||||||
|
|
||||||
|
for (const txn of txns) {
|
||||||
|
if (txn.status === 'voided') {
|
||||||
|
voidCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (txn.status !== 'completed') continue
|
||||||
|
|
||||||
|
const total = parseFloat(txn.total ?? '0')
|
||||||
|
const discAmt = parseFloat(txn.discountTotal ?? '0')
|
||||||
|
|
||||||
|
if (txn.transactionType === 'refund') {
|
||||||
|
refundTotal += total
|
||||||
|
} else {
|
||||||
|
grossSales += total
|
||||||
|
transactionCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discAmt > 0) {
|
||||||
|
discountTotalSum += discAmt
|
||||||
|
discountCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = txn.paymentMethod ?? 'unknown'
|
||||||
|
if (!payments[method]) payments[method] = { count: 0, total: 0 }
|
||||||
|
payments[method].count++
|
||||||
|
payments[method].total += total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash accountability
|
||||||
|
const cashPayment = payments['cash'] ?? { count: 0, total: 0 }
|
||||||
|
const cashRounding = txns
|
||||||
|
.filter((t) => t.status === 'completed' && t.paymentMethod === 'cash')
|
||||||
|
.reduce((sum, t) => sum + parseFloat(t.roundingAdjustment ?? '0'), 0)
|
||||||
|
const cashSales = cashPayment.total + cashRounding
|
||||||
|
|
||||||
|
// Adjustments
|
||||||
|
const adjRows = await db
|
||||||
|
.select()
|
||||||
|
.from(drawerAdjustments)
|
||||||
|
.where(eq(drawerAdjustments.drawerSessionId, drawerSessionId))
|
||||||
|
|
||||||
|
let cashIn = 0
|
||||||
|
let cashOut = 0
|
||||||
|
for (const adj of adjRows) {
|
||||||
|
if (adj.type === 'cash_in') cashIn += parseFloat(adj.amount)
|
||||||
|
else cashOut += parseFloat(adj.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openingBalance = parseFloat(session.openingBalance)
|
||||||
|
const expectedBalance = openingBalance + cashSales + cashIn - cashOut
|
||||||
|
const actualBalance = session.closingBalance ? parseFloat(session.closingBalance) : null
|
||||||
|
const overShort = actualBalance !== null ? Math.round((actualBalance - expectedBalance) * 100) / 100 : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
id: session.id,
|
||||||
|
openedAt: session.openedAt.toISOString(),
|
||||||
|
closedAt: session.closedAt?.toISOString() ?? null,
|
||||||
|
openingBalance: session.openingBalance,
|
||||||
|
closingBalance: session.closingBalance,
|
||||||
|
expectedBalance: session.expectedBalance,
|
||||||
|
overShort: session.overShort,
|
||||||
|
status: session.status,
|
||||||
|
notes: session.notes,
|
||||||
|
denominations: session.denominations,
|
||||||
|
register,
|
||||||
|
openedBy: openedByUser ?? null,
|
||||||
|
closedBy: closedByUser,
|
||||||
|
},
|
||||||
|
sales: {
|
||||||
|
grossSales: Math.round(grossSales * 100) / 100,
|
||||||
|
netSales: Math.round((grossSales - refundTotal) * 100) / 100,
|
||||||
|
transactionCount,
|
||||||
|
voidCount,
|
||||||
|
refundTotal: Math.round(refundTotal * 100) / 100,
|
||||||
|
},
|
||||||
|
payments,
|
||||||
|
discounts: {
|
||||||
|
total: Math.round(discountTotalSum * 100) / 100,
|
||||||
|
count: discountCount,
|
||||||
|
},
|
||||||
|
cash: {
|
||||||
|
openingBalance,
|
||||||
|
cashSales: Math.round(cashSales * 100) / 100,
|
||||||
|
cashIn: Math.round(cashIn * 100) / 100,
|
||||||
|
cashOut: Math.round(cashOut * 100) / 100,
|
||||||
|
expectedBalance: Math.round(expectedBalance * 100) / 100,
|
||||||
|
actualBalance,
|
||||||
|
overShort,
|
||||||
|
},
|
||||||
|
adjustments: adjRows.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
type: a.type,
|
||||||
|
amount: a.amount,
|
||||||
|
reason: a.reason,
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDailyReport(db: PostgresJsDatabase<any>, locationId: string, date: string) {
|
||||||
|
// Get location info
|
||||||
|
const [location] = await db
|
||||||
|
.select({ id: locations.id, name: locations.name, timezone: locations.timezone })
|
||||||
|
.from(locations)
|
||||||
|
.where(eq(locations.id, locationId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!location) throw new NotFoundError('Location')
|
||||||
|
|
||||||
|
// Find all drawer sessions opened at this location on the given date
|
||||||
|
const dayStart = new Date(`${date}T00:00:00`)
|
||||||
|
const dayEnd = new Date(`${date}T00:00:00`)
|
||||||
|
dayEnd.setDate(dayEnd.getDate() + 1)
|
||||||
|
|
||||||
|
const sessions = await db
|
||||||
|
.select()
|
||||||
|
.from(drawerSessions)
|
||||||
|
.where(and(
|
||||||
|
eq(drawerSessions.locationId, locationId),
|
||||||
|
gte(drawerSessions.openedAt, dayStart),
|
||||||
|
lt(drawerSessions.openedAt, dayEnd),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Get individual reports for each session
|
||||||
|
const sessionReports = await Promise.all(
|
||||||
|
sessions.map((s) => this.getDrawerReport(db, s.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Aggregate
|
||||||
|
const sales = { grossSales: 0, netSales: 0, transactionCount: 0, voidCount: 0, refundTotal: 0 }
|
||||||
|
const payments: Record<string, PaymentBreakdown> = {}
|
||||||
|
const discounts = { total: 0, count: 0 }
|
||||||
|
const cash = { totalOpening: 0, totalCashSales: 0, totalCashIn: 0, totalCashOut: 0, totalExpected: 0, totalActual: 0, totalOverShort: 0 }
|
||||||
|
|
||||||
|
for (const report of sessionReports) {
|
||||||
|
sales.grossSales += report.sales.grossSales
|
||||||
|
sales.netSales += report.sales.netSales
|
||||||
|
sales.transactionCount += report.sales.transactionCount
|
||||||
|
sales.voidCount += report.sales.voidCount
|
||||||
|
sales.refundTotal += report.sales.refundTotal
|
||||||
|
|
||||||
|
for (const [method, data] of Object.entries(report.payments)) {
|
||||||
|
if (!payments[method]) payments[method] = { count: 0, total: 0 }
|
||||||
|
payments[method].count += data.count
|
||||||
|
payments[method].total += data.total
|
||||||
|
}
|
||||||
|
|
||||||
|
discounts.total += report.discounts.total
|
||||||
|
discounts.count += report.discounts.count
|
||||||
|
|
||||||
|
cash.totalOpening += report.cash.openingBalance
|
||||||
|
cash.totalCashSales += report.cash.cashSales
|
||||||
|
cash.totalCashIn += report.cash.cashIn
|
||||||
|
cash.totalCashOut += report.cash.cashOut
|
||||||
|
cash.totalExpected += report.cash.expectedBalance
|
||||||
|
if (report.cash.actualBalance !== null) cash.totalActual += report.cash.actualBalance
|
||||||
|
if (report.cash.overShort !== null) cash.totalOverShort += report.cash.overShort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round all aggregated values
|
||||||
|
for (const key of Object.keys(sales) as (keyof typeof sales)[]) {
|
||||||
|
sales[key] = Math.round(sales[key] * 100) / 100
|
||||||
|
}
|
||||||
|
for (const data of Object.values(payments)) {
|
||||||
|
data.total = Math.round(data.total * 100) / 100
|
||||||
|
}
|
||||||
|
discounts.total = Math.round(discounts.total * 100) / 100
|
||||||
|
for (const key of Object.keys(cash) as (keyof typeof cash)[]) {
|
||||||
|
cash[key] = Math.round(cash[key] * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
location: { id: location.id, name: location.name },
|
||||||
|
sessions: sessionReports.map((r) => ({
|
||||||
|
id: r.session.id,
|
||||||
|
register: r.session.register,
|
||||||
|
openedBy: r.session.openedBy,
|
||||||
|
openedAt: r.session.openedAt,
|
||||||
|
closedAt: r.session.closedAt,
|
||||||
|
status: r.session.status,
|
||||||
|
overShort: r.cash.overShort,
|
||||||
|
grossSales: r.sales.grossSales,
|
||||||
|
})),
|
||||||
|
sales,
|
||||||
|
payments,
|
||||||
|
discounts,
|
||||||
|
cash,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -329,6 +329,7 @@ export const TransactionService = {
|
|||||||
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
|
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
|
||||||
|
|
||||||
// Require an open drawer session at the transaction's location
|
// Require an open drawer session at the transaction's location
|
||||||
|
let drawerSessionId: string | null = null
|
||||||
if (txn.locationId) {
|
if (txn.locationId) {
|
||||||
const [openDrawer] = await db
|
const [openDrawer] = await db
|
||||||
.select({ id: drawerSessions.id })
|
.select({ id: drawerSessions.id })
|
||||||
@@ -338,6 +339,7 @@ export const TransactionService = {
|
|||||||
if (!openDrawer) {
|
if (!openDrawer) {
|
||||||
throw new ValidationError('Cannot complete transaction without an open drawer at this location')
|
throw new ValidationError('Cannot complete transaction without an open drawer at this location')
|
||||||
}
|
}
|
||||||
|
drawerSessionId = openDrawer.id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate cash payment (with optional nickel rounding)
|
// Validate cash payment (with optional nickel rounding)
|
||||||
@@ -399,6 +401,7 @@ export const TransactionService = {
|
|||||||
changeGiven,
|
changeGiven,
|
||||||
roundingAdjustment: roundingAdjustment.toString(),
|
roundingAdjustment: roundingAdjustment.toString(),
|
||||||
checkNumber: input.checkNumber,
|
checkNumber: input.checkNumber,
|
||||||
|
drawerSessionId,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ export {
|
|||||||
DrawerOpenSchema,
|
DrawerOpenSchema,
|
||||||
DrawerCloseSchema,
|
DrawerCloseSchema,
|
||||||
DrawerAdjustmentSchema,
|
DrawerAdjustmentSchema,
|
||||||
|
RegisterCreateSchema,
|
||||||
|
RegisterUpdateSchema,
|
||||||
} from './pos.schema.js'
|
} from './pos.schema.js'
|
||||||
export type {
|
export type {
|
||||||
TransactionCreateInput,
|
TransactionCreateInput,
|
||||||
@@ -192,6 +194,8 @@ export type {
|
|||||||
DrawerOpenInput,
|
DrawerOpenInput,
|
||||||
DrawerCloseInput,
|
DrawerCloseInput,
|
||||||
DrawerAdjustmentInput,
|
DrawerAdjustmentInput,
|
||||||
|
RegisterCreateInput,
|
||||||
|
RegisterUpdateInput,
|
||||||
} from './pos.schema.js'
|
} from './pos.schema.js'
|
||||||
|
|
||||||
export { LogLevel, AppConfigUpdateSchema } from './config.schema.js'
|
export { LogLevel, AppConfigUpdateSchema } from './config.schema.js'
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export type DiscountUpdateInput = z.infer<typeof DiscountUpdateSchema>
|
|||||||
|
|
||||||
export const DrawerOpenSchema = z.object({
|
export const DrawerOpenSchema = z.object({
|
||||||
locationId: opt(z.string().uuid()),
|
locationId: opt(z.string().uuid()),
|
||||||
|
registerId: opt(z.string().uuid()),
|
||||||
openingBalance: z.coerce.number().min(0),
|
openingBalance: z.coerce.number().min(0),
|
||||||
})
|
})
|
||||||
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
|
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
|
||||||
@@ -119,3 +120,17 @@ export const DrawerCloseSchema = z.object({
|
|||||||
notes: opt(z.string()),
|
notes: opt(z.string()),
|
||||||
})
|
})
|
||||||
export type DrawerCloseInput = z.infer<typeof DrawerCloseSchema>
|
export type DrawerCloseInput = z.infer<typeof DrawerCloseSchema>
|
||||||
|
|
||||||
|
// --- Register schemas ---
|
||||||
|
|
||||||
|
export const RegisterCreateSchema = z.object({
|
||||||
|
locationId: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
})
|
||||||
|
export type RegisterCreateInput = z.infer<typeof RegisterCreateSchema>
|
||||||
|
|
||||||
|
export const RegisterUpdateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
export type RegisterUpdateInput = z.infer<typeof RegisterUpdateSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user