feat: add POS register screen with full-screen touch-optimized layout

Standalone register at /pos bypassing the admin sidebar layout:
- Two-panel layout: product search/grid (60%) + cart/payment (40%)
- Product search with barcode scan support (UPC lookup on Enter)
- Custom item entry dialog for ad-hoc items
- Cart with line items, tax, totals, and remove-item support
- Payment dialogs: cash (quick amounts + change calc), card, check
- Drawer open/close with balance reconciliation and over/short
- Auto-creates pending transaction on first item added
- POS link added to admin sidebar nav (module-gated)
- Zustand store for POS session state, React Query for server data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 19:29:37 +00:00
parent bd5f0ca511
commit bd3a25aa1c
11 changed files with 1180 additions and 1 deletions

View File

@@ -0,0 +1,91 @@
import { Link, useRouter } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store'
import { usePOSStore } from '@/stores/pos.store'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ArrowLeft, LogOut, DollarSign } from 'lucide-react'
import type { DrawerSession } from '@/api/pos'
import { useState } from 'react'
import { POSDrawerDialog } from './pos-drawer-dialog'
interface POSTopBarProps {
locations: { id: string; name: string }[]
locationId: string | null
onLocationChange: (id: string) => void
drawer: DrawerSession | null
}
export function POSTopBar({ locations, locationId, onLocationChange, drawer }: POSTopBarProps) {
const router = useRouter()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const [drawerDialogOpen, setDrawerDialogOpen] = useState(false)
const drawerOpen = drawer?.status === 'open'
function handleLogout() {
logout()
router.navigate({ to: '/login', replace: true })
}
return (
<>
<div className="h-12 border-b border-border bg-card flex items-center justify-between px-3 shrink-0">
{/* Left: back + location */}
<div className="flex items-center gap-3">
<Link to="/" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Admin</span>
</Link>
{locations.length > 1 ? (
<Select value={locationId ?? ''} onValueChange={onLocationChange}>
<SelectTrigger className="h-8 w-48 text-sm">
<SelectValue placeholder="Select location" />
</SelectTrigger>
<SelectContent>
{locations.map((loc) => (
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
))}
</SelectContent>
</Select>
) : locations.length === 1 ? (
<span className="text-sm font-medium">{locations[0].name}</span>
) : null}
</div>
{/* Center: drawer status */}
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2"
onClick={() => setDrawerDialogOpen(true)}
>
<DollarSign className="h-4 w-4" />
{drawerOpen ? (
<Badge variant="default" className="text-xs">
Drawer Open &mdash; ${parseFloat(drawer!.openingBalance).toFixed(2)}
</Badge>
) : (
<Badge variant="outline" className="text-xs">Drawer Closed</Badge>
)}
</Button>
{/* Right: user + logout */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{user?.firstName}</span>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleLogout} title="Sign out">
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
<POSDrawerDialog
open={drawerDialogOpen}
onOpenChange={setDrawerDialogOpen}
drawer={drawer}
/>
</>
)
}