feat: add cash in/out UI and hide drawer balance from cashier

- Cash In / Cash Out buttons in drawer dialog when open
- Amount + reason form, adjustment history with IN/OUT badges
- Drawer badge shows "Drawer Open" without balance (manager info only)
- API helpers for addAdjustment and getAdjustments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 20:36:49 +00:00
parent c66554f932
commit 8aed3e8f88
3 changed files with 146 additions and 6 deletions

View File

@@ -90,9 +90,20 @@ export interface Product {
// --- Query Keys ---
export interface DrawerAdjustment {
id: string
drawerSessionId: string
type: string
amount: string
reason: string
createdBy: string
createdAt: string
}
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,
products: (search: string) => ['pos', 'products', search] as const,
discounts: ['pos', 'discounts'] as const,
}
@@ -160,4 +171,10 @@ export const posMutations = {
lookupUpc: (upc: string) =>
api.get<Product>(`/v1/products/lookup/upc/${upc}`),
addAdjustment: (drawerId: string, data: { type: string; amount: number; reason: string }) =>
api.post<DrawerAdjustment>(`/v1/drawer/${drawerId}/adjustments`, data),
getAdjustments: (drawerId: string) =>
api.get<{ data: DrawerAdjustment[] }>(`/v1/drawer/${drawerId}/adjustments`),
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type DrawerSession } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'
import { toast } from 'sonner'
interface POSDrawerDialogProps {
@@ -23,6 +25,17 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
const [openingBalance, setOpeningBalance] = useState('200')
const [closingBalance, setClosingBalance] = useState('')
const [notes, setNotes] = useState('')
const [adjustView, setAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
const [adjAmount, setAdjAmount] = useState('')
const [adjReason, setAdjReason] = useState('')
// Fetch adjustments for open drawer
const { data: adjData } = useQuery({
queryKey: posKeys.drawerAdjustments(drawer?.id ?? ''),
queryFn: () => posMutations.getAdjustments(drawer!.id),
enabled: !!drawer?.id && isOpen,
})
const adjustments = adjData?.data ?? []
const openMutation = useMutation({
mutationFn: () =>
@@ -59,11 +72,78 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
onError: (err) => toast.error(err.message),
})
const adjustMutation = useMutation({
mutationFn: () =>
posMutations.addAdjustment(drawer!.id, {
type: adjustView!,
amount: parseFloat(adjAmount) || 0,
reason: adjReason,
}),
onSuccess: (adj) => {
queryClient.invalidateQueries({ queryKey: posKeys.drawerAdjustments(drawer!.id) })
toast.success(`${adj.type === 'cash_in' ? 'Cash added' : 'Cash removed'}: $${parseFloat(adj.amount).toFixed(2)}`)
setAdjustView(null)
setAdjAmount('')
setAdjReason('')
},
onError: (err) => toast.error(err.message),
})
// Adjustment entry view
if (adjustView && isOpen) {
const isCashIn = adjustView === 'cash_in'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{isCashIn ? 'Cash In' : 'Cash Out'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Amount *</Label>
<Input
type="number"
step="0.01"
min="0.01"
value={adjAmount}
onChange={(e) => setAdjAmount(e.target.value)}
placeholder="0.00"
className="h-11 text-lg"
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
<Input
value={adjReason}
onChange={(e) => setAdjReason(e.target.value)}
placeholder={isCashIn ? 'e.g. Extra change' : 'e.g. Bank deposit'}
className="h-11"
/>
</div>
<div className="flex gap-2">
<Button
className="flex-1 h-12"
onClick={() => adjustMutation.mutate()}
disabled={!adjAmount || !adjReason || adjustMutation.isPending}
>
{adjustMutation.isPending ? 'Saving...' : isCashIn ? 'Add Cash' : 'Remove Cash'}
</Button>
<Button variant="outline" className="h-12" onClick={() => setAdjustView(null)}>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{isOpen ? 'Close Drawer' : 'Open Drawer'}</DialogTitle>
<DialogTitle>{isOpen ? 'Drawer' : 'Open Drawer'}</DialogTitle>
</DialogHeader>
{isOpen ? (
@@ -78,7 +158,53 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
</div>
</div>
{/* Cash In / Cash Out buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
className="h-11 gap-2"
onClick={() => setAdjustView('cash_in')}
>
<ArrowDownToLine className="h-4 w-4 text-green-600" />
Cash In
</Button>
<Button
variant="outline"
className="h-11 gap-2"
onClick={() => setAdjustView('cash_out')}
>
<ArrowUpFromLine className="h-4 w-4 text-red-600" />
Cash Out
</Button>
</div>
{/* Adjustment history */}
{adjustments.length > 0 && (
<>
<Separator />
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Adjustments</span>
{adjustments.map((adj) => (
<div key={adj.id} className="flex items-center justify-between text-sm py-1">
<div className="flex items-center gap-2">
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
</Badge>
<span className="text-muted-foreground truncate max-w-[140px]">{adj.reason}</span>
</div>
<span className={adj.type === 'cash_in' ? 'text-green-600' : 'text-red-600'}>
{adj.type === 'cash_in' ? '+' : '-'}${parseFloat(adj.amount).toFixed(2)}
</span>
</div>
))}
</div>
</>
)}
<Separator />
{/* Close drawer */}
<div className="space-y-2">
<Label>Closing Balance *</Label>
<Input
@@ -89,7 +215,6 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
onChange={(e) => setClosingBalance(e.target.value)}
placeholder="Count the cash in the drawer"
className="h-11 text-lg"
autoFocus
/>
</div>
<div className="space-y-2">

View File

@@ -64,9 +64,7 @@ export function POSTopBar({ locations, locationId, onLocationChange, drawer }: P
>
<DollarSign className="h-4 w-4" />
{drawerOpen ? (
<Badge variant="default" className="text-xs">
Drawer Open &mdash; ${parseFloat(drawer!.openingBalance).toFixed(2)}
</Badge>
<Badge variant="default" className="text-xs">Drawer Open</Badge>
) : (
<Badge variant="outline" className="text-xs">Drawer Closed</Badge>
)}