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,163 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
// --- Types ---
export interface Transaction {
id: string
locationId: string | null
transactionNumber: string
accountId: string | null
repairTicketId: string | null
repairBatchId: string | null
transactionType: string
status: string
subtotal: string
discountTotal: string
taxTotal: string
total: string
paymentMethod: string | null
amountTendered: string | null
changeGiven: string | null
checkNumber: string | null
roundingAdjustment: string
taxExempt: boolean
taxExemptReason: string | null
processedBy: string
drawerSessionId: string | null
notes: string | null
completedAt: string | null
createdAt: string
updatedAt: string
lineItems?: TransactionLineItem[]
}
export interface TransactionLineItem {
id: string
transactionId: string
productId: string | null
inventoryUnitId: string | null
description: string
qty: number
unitPrice: string
discountAmount: string
discountReason: string | null
taxRate: string
taxAmount: string
lineTotal: string
createdAt: string
}
export interface DrawerSession {
id: string
locationId: string | null
openedBy: string
closedBy: string | null
openingBalance: string
closingBalance: string | null
expectedBalance: string | null
overShort: string | null
denominations: Record<string, number> | null
status: string
notes: string | null
openedAt: string
closedAt: string | null
}
export interface Discount {
id: string
name: string
discountType: string
discountValue: string
appliesTo: string
requiresApprovalAbove: string | null
isActive: boolean
}
export interface Product {
id: string
name: string
sku: string | null
upc: string | null
description: string | null
sellingPrice: string | null
costPrice: string | null
qtyOnHand: number | null
taxCategory: string
isSerialized: boolean
isActive: boolean
}
// --- Query Keys ---
export const posKeys = {
transaction: (id: string) => ['pos', 'transaction', id] as const,
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
products: (search: string) => ['pos', 'products', search] as const,
discounts: ['pos', 'discounts'] as const,
}
// --- Query Options ---
export function transactionOptions(id: string | null) {
return queryOptions({
queryKey: posKeys.transaction(id ?? ''),
queryFn: () => api.get<Transaction>(`/v1/transactions/${id}`),
enabled: !!id,
})
}
export function currentDrawerOptions(locationId: string | null) {
return queryOptions({
queryKey: posKeys.drawer(locationId ?? ''),
queryFn: () => api.get<DrawerSession>('/v1/drawer/current', { locationId }),
enabled: !!locationId,
retry: false,
})
}
export function productSearchOptions(search: string) {
return queryOptions({
queryKey: posKeys.products(search),
queryFn: () => api.get<{ data: Product[]; pagination: any }>('/v1/products', { q: search, limit: 24, isActive: true }),
enabled: search.length >= 1,
})
}
export function discountListOptions() {
return queryOptions({
queryKey: posKeys.discounts,
queryFn: () => api.get<Discount[]>('/v1/discounts/all'),
})
}
// --- Mutations ---
export const posMutations = {
createTransaction: (data: { transactionType: string; locationId?: string }) =>
api.post<Transaction>('/v1/transactions', data),
addLineItem: (txnId: string, data: { productId?: string; inventoryUnitId?: string; description: string; qty: number; unitPrice: number }) =>
api.post<TransactionLineItem>(`/v1/transactions/${txnId}/line-items`, data),
removeLineItem: (txnId: string, lineItemId: string) =>
api.del<TransactionLineItem>(`/v1/transactions/${txnId}/line-items/${lineItemId}`),
applyDiscount: (txnId: string, data: { discountId?: string; amount: number; reason: string; lineItemId?: string }) =>
api.post<Transaction>(`/v1/transactions/${txnId}/discounts`, data),
complete: (txnId: string, data: { paymentMethod: string; amountTendered?: number; checkNumber?: string }) =>
api.post<Transaction>(`/v1/transactions/${txnId}/complete`, data),
void: (txnId: string) =>
api.post<Transaction>(`/v1/transactions/${txnId}/void`, {}),
openDrawer: (data: { locationId?: string; openingBalance: number }) =>
api.post<DrawerSession>('/v1/drawer/open', data),
closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) =>
api.post<DrawerSession>(`/v1/drawer/${id}/close`, data),
lookupUpc: (upc: string) =>
api.get<Product>(`/v1/products/lookup/upc/${upc}`),
}

View File

@@ -0,0 +1,179 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { X, Banknote, CreditCard, FileText, Ban } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { POSPaymentDialog } from './pos-payment-dialog'
interface POSCartPanelProps {
transaction: Transaction | null
}
export function POSCartPanel({ transaction }: POSCartPanelProps) {
const queryClient = useQueryClient()
const { currentTransactionId, setTransaction } = usePOSStore()
const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
const lineItems = transaction?.lineItems ?? []
const removeItemMutation = useMutation({
mutationFn: (lineItemId: string) =>
posMutations.removeLineItem(currentTransactionId!, lineItemId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
},
onError: (err) => toast.error(err.message),
})
const voidMutation = useMutation({
mutationFn: () => posMutations.void(currentTransactionId!),
onSuccess: () => {
setTransaction(null)
toast.success('Transaction voided')
},
onError: (err) => toast.error(err.message),
})
const subtotal = parseFloat(transaction?.subtotal ?? '0')
const discountTotal = parseFloat(transaction?.discountTotal ?? '0')
const taxTotal = parseFloat(transaction?.taxTotal ?? '0')
const total = parseFloat(transaction?.total ?? '0')
const hasItems = lineItems.length > 0
const isPending = transaction?.status === 'pending'
function handlePaymentComplete() {
setPaymentMethod(null)
setTransaction(null)
}
return (
<div className="flex flex-col h-full bg-card">
{/* Header */}
<div className="p-3 border-b border-border">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Current Sale</h2>
{transaction && (
<span className="text-xs text-muted-foreground font-mono">
{transaction.transactionNumber}
</span>
)}
</div>
</div>
{/* Line items */}
<div className="flex-1 overflow-y-auto">
{lineItems.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No items yet
</div>
) : (
<div className="divide-y divide-border">
{lineItems.map((item) => (
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.description}</p>
<p className="text-xs text-muted-foreground">
{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}
{parseFloat(item.taxAmount) > 0 && (
<span className="ml-2">tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
)}
</p>
</div>
<span className="text-sm font-medium tabular-nums">
${parseFloat(item.lineTotal).toFixed(2)}
</span>
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 shrink-0"
onClick={() => removeItemMutation.mutate(item.id)}
disabled={removeItemMutation.isPending}
>
<X className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Totals + payment */}
<div className="shrink-0 border-t border-border">
<div className="px-3 py-2 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">${subtotal.toFixed(2)}</span>
</div>
{discountTotal > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount</span>
<span className="tabular-nums">-${discountTotal.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Tax</span>
<span className="tabular-nums">${taxTotal.toFixed(2)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold pt-1">
<span>Total</span>
<span className="tabular-nums">${total.toFixed(2)}</span>
</div>
</div>
{/* Payment buttons */}
<div className="p-3 grid grid-cols-2 gap-2">
<Button
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => setPaymentMethod('cash')}
>
<Banknote className="h-4 w-4" />
Cash
</Button>
<Button
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => setPaymentMethod('card_present')}
>
<CreditCard className="h-4 w-4" />
Card
</Button>
<Button
variant="outline"
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => setPaymentMethod('check')}
>
<FileText className="h-4 w-4" />
Check
</Button>
<Button
variant="destructive"
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => voidMutation.mutate()}
>
<Ban className="h-4 w-4" />
Void
</Button>
</div>
</div>
{/* Payment dialog */}
{paymentMethod && transaction && (
<POSPaymentDialog
open={!!paymentMethod}
onOpenChange={(open) => { if (!open) setPaymentMethod(null) }}
paymentMethod={paymentMethod}
transaction={transaction}
onComplete={handlePaymentComplete}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { 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'
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 { toast } from 'sonner'
interface POSDrawerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawer: DrawerSession | null
}
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
const queryClient = useQueryClient()
const { locationId, setDrawerSession } = usePOSStore()
const isOpen = drawer?.status === 'open'
const [openingBalance, setOpeningBalance] = useState('200')
const [closingBalance, setClosingBalance] = useState('')
const [notes, setNotes] = useState('')
const openMutation = useMutation({
mutationFn: () =>
posMutations.openDrawer({
locationId: locationId ?? undefined,
openingBalance: parseFloat(openingBalance) || 0,
}),
onSuccess: (session) => {
setDrawerSession(session.id)
queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
toast.success('Drawer opened')
onOpenChange(false)
},
onError: (err) => toast.error(err.message),
})
const closeMutation = useMutation({
mutationFn: () =>
posMutations.closeDrawer(drawer!.id, {
closingBalance: parseFloat(closingBalance) || 0,
notes: notes || undefined,
}),
onSuccess: (session) => {
setDrawerSession(null)
queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
const overShort = parseFloat(session.overShort ?? '0')
if (Math.abs(overShort) < 0.01) {
toast.success('Drawer closed - balanced')
} else {
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
}
onOpenChange(false)
},
onError: (err) => toast.error(err.message),
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{isOpen ? 'Close Drawer' : 'Open Drawer'}</DialogTitle>
</DialogHeader>
{isOpen ? (
<div className="space-y-4">
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Opening Balance</span>
<span>${parseFloat(drawer!.openingBalance).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Opened</span>
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Closing Balance *</Label>
<Input
type="number"
step="0.01"
min="0"
value={closingBalance}
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">
<Label>Notes</Label>
<Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="End of shift notes"
className="h-11"
/>
</div>
<Button
className="w-full h-12"
onClick={() => closeMutation.mutate()}
disabled={!closingBalance || closeMutation.isPending}
>
{closeMutation.isPending ? 'Closing...' : 'Close Drawer'}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Opening Balance *</Label>
<Input
type="number"
step="0.01"
min="0"
value={openingBalance}
onChange={(e) => setOpeningBalance(e.target.value)}
placeholder="Starting cash amount"
className="h-11 text-lg"
autoFocus
/>
</div>
<Button
className="w-full h-12"
onClick={() => openMutation.mutate()}
disabled={!openingBalance || openMutation.isPending}
>
{openMutation.isPending ? 'Opening...' : 'Open Drawer'}
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,264 @@
import { useState, useRef, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { productSearchOptions, posMutations, posKeys, type Transaction, type Product } from '@/api/pos'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Search, ScanBarcode, Wrench, PenLine } from 'lucide-react'
import { toast } from 'sonner'
interface POSItemPanelProps {
transaction: Transaction | null
}
export function POSItemPanel({ transaction }: POSItemPanelProps) {
const queryClient = useQueryClient()
const { currentTransactionId, setTransaction, locationId } = usePOSStore()
const [search, setSearch] = useState('')
const [customOpen, setCustomOpen] = useState(false)
const [customDesc, setCustomDesc] = useState('')
const [customPrice, setCustomPrice] = useState('')
const [customQty, setCustomQty] = useState('1')
const searchRef = useRef<HTMLInputElement>(null)
// Debounced product search
const { data: productsData, isLoading: searchLoading } = useQuery({
...productSearchOptions(search),
enabled: search.length >= 1,
})
const products = productsData?.data ?? []
// Add line item mutation
const addItemMutation = useMutation({
mutationFn: async (product: Product) => {
let txnId = currentTransactionId
// Auto-create transaction if none exists
if (!txnId) {
const txn = await posMutations.createTransaction({
transactionType: 'sale',
locationId: locationId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
productId: product.id,
description: product.name,
qty: 1,
unitPrice: parseFloat(product.sellingPrice ?? '0'),
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
},
onError: (err) => toast.error(err.message),
})
// Custom item mutation
const addCustomMutation = useMutation({
mutationFn: async () => {
let txnId = currentTransactionId
if (!txnId) {
const txn = await posMutations.createTransaction({
transactionType: 'sale',
locationId: locationId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
description: customDesc,
qty: parseInt(customQty) || 1,
unitPrice: parseFloat(customPrice) || 0,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
setCustomOpen(false)
setCustomDesc('')
setCustomPrice('')
setCustomQty('1')
},
onError: (err) => toast.error(err.message),
})
// UPC scan
const scanMutation = useMutation({
mutationFn: async (upc: string) => {
const product = await posMutations.lookupUpc(upc)
let txnId = currentTransactionId
if (!txnId) {
const txn = await posMutations.createTransaction({
transactionType: 'sale',
locationId: locationId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
productId: product.id,
description: product.name,
qty: 1,
unitPrice: parseFloat(product.sellingPrice ?? '0'),
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
setSearch('')
toast.success('Item scanned')
},
onError: (err) => toast.error(err.message),
})
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
// Barcode scanners typically send Enter after the code
if (e.key === 'Enter' && search.length >= 6) {
// Looks like a UPC — try scanning
scanMutation.mutate(search)
}
}, [search, scanMutation])
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search products or scan barcode..."
className="pl-10 h-11 text-base"
autoFocus
/>
</div>
</div>
{/* Product grid */}
<div className="flex-1 overflow-y-auto p-3">
{searchLoading ? (
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-lg" />
))}
</div>
) : products.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{products.map((product) => (
<button
key={product.id}
onClick={() => addItemMutation.mutate(product)}
disabled={addItemMutation.isPending}
className="flex flex-col items-start p-3 rounded-lg border border-border bg-card hover:bg-accent active:bg-accent/80 transition-colors text-left min-h-[80px]"
>
<span className="font-medium text-sm line-clamp-2">{product.name}</span>
<div className="mt-auto flex items-center justify-between w-full pt-1">
<span className="text-base font-semibold">${parseFloat(product.sellingPrice ?? '0').toFixed(2)}</span>
{product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>}
</div>
{product.isSerialized ? (
<span className="text-[10px] text-muted-foreground">Serialized</span>
) : product.qtyOnHand !== null ? (
<span className="text-[10px] text-muted-foreground">{product.qtyOnHand} in stock</span>
) : null}
</button>
))}
</div>
) : search.length >= 1 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
No products found for "{search}"
</div>
) : (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
Search for products to add to the sale
</div>
)}
</div>
{/* Quick action buttons */}
<div className="p-3 border-t border-border flex gap-2">
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
onClick={() => searchRef.current?.focus()}
>
<ScanBarcode className="h-4 w-4" />
Scan
</Button>
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
disabled
>
<Wrench className="h-4 w-4" />
Repairs
</Button>
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
onClick={() => setCustomOpen(true)}
>
<PenLine className="h-4 w-4" />
Custom
</Button>
</div>
{/* Custom item dialog */}
<Dialog open={customOpen} onOpenChange={setCustomOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Custom Item</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => { e.preventDefault(); addCustomMutation.mutate() }}
className="space-y-4"
>
<div className="space-y-2">
<Label>Description *</Label>
<Input
value={customDesc}
onChange={(e) => setCustomDesc(e.target.value)}
placeholder="Item description"
required
className="h-11"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Price *</Label>
<Input
type="number"
step="0.01"
min="0"
value={customPrice}
onChange={(e) => setCustomPrice(e.target.value)}
placeholder="0.00"
required
className="h-11"
/>
</div>
<div className="space-y-2">
<Label>Qty</Label>
<Input
type="number"
min="1"
value={customQty}
onChange={(e) => setCustomQty(e.target.value)}
className="h-11"
/>
</div>
</div>
<Button type="submit" className="w-full h-11" disabled={addCustomMutation.isPending}>
{addCustomMutation.isPending ? 'Adding...' : 'Add Item'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,202 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
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 { CheckCircle } from 'lucide-react'
import { toast } from 'sonner'
interface POSPaymentDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
paymentMethod: string
transaction: Transaction
onComplete: () => void
}
export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transaction, onComplete }: POSPaymentDialogProps) {
const queryClient = useQueryClient()
const { currentTransactionId } = usePOSStore()
const total = parseFloat(transaction.total)
const [amountTendered, setAmountTendered] = useState('')
const [checkNumber, setCheckNumber] = useState('')
const [completed, setCompleted] = useState(false)
const [result, setResult] = useState<Transaction | null>(null)
const completeMutation = useMutation({
mutationFn: () => {
const data: { paymentMethod: string; amountTendered?: number; checkNumber?: string } = {
paymentMethod,
}
if (paymentMethod === 'cash') {
data.amountTendered = parseFloat(amountTendered) || 0
}
if (paymentMethod === 'check') {
data.checkNumber = checkNumber || undefined
}
return posMutations.complete(currentTransactionId!, data)
},
onSuccess: (txn) => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
setResult(txn)
setCompleted(true)
},
onError: (err) => toast.error(err.message),
})
const tenderedAmount = parseFloat(amountTendered) || 0
const changeDue = paymentMethod === 'cash' ? Math.max(0, tenderedAmount - total) : 0
const canComplete = paymentMethod === 'cash'
? tenderedAmount >= total
: true
function handleDone() {
onComplete()
onOpenChange(false)
}
const QUICK_AMOUNTS = [1, 5, 10, 20, 50, 100]
if (completed && result) {
const changeGiven = parseFloat(result.changeGiven ?? '0')
const roundingAdj = parseFloat(result.roundingAdjustment ?? '0')
return (
<Dialog open={open} onOpenChange={() => handleDone()}>
<DialogContent className="max-w-sm">
<div className="flex flex-col items-center text-center space-y-4 py-4">
<CheckCircle className="h-12 w-12 text-green-500" />
<h2 className="text-xl font-bold">Sale Complete</h2>
<p className="text-muted-foreground text-sm">{result.transactionNumber}</p>
<div className="w-full text-sm space-y-1">
<div className="flex justify-between font-semibold text-base">
<span>Total</span>
<span>${parseFloat(result.total).toFixed(2)}</span>
</div>
{roundingAdj !== 0 && (
<div className="flex justify-between text-muted-foreground">
<span>Rounding</span>
<span>{roundingAdj > 0 ? '+' : ''}{roundingAdj.toFixed(2)}</span>
</div>
)}
{paymentMethod === 'cash' && (
<>
<div className="flex justify-between">
<span>Tendered</span>
<span>${parseFloat(result.amountTendered ?? '0').toFixed(2)}</span>
</div>
{changeGiven > 0 && (
<div className="flex justify-between text-lg font-bold text-green-600">
<span>Change Due</span>
<span>${changeGiven.toFixed(2)}</span>
</div>
)}
</>
)}
</div>
<Button className="w-full h-12 text-base" onClick={handleDone}>
New Sale
</Button>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{paymentMethod === 'cash' ? 'Cash Payment' : paymentMethod === 'check' ? 'Check Payment' : 'Card Payment'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-between text-lg font-bold">
<span>Total Due</span>
<span>${total.toFixed(2)}</span>
</div>
<Separator />
{paymentMethod === 'cash' && (
<>
<div className="space-y-2">
<Label>Amount Tendered</Label>
<Input
type="number"
step="0.01"
min="0"
value={amountTendered}
onChange={(e) => setAmountTendered(e.target.value)}
placeholder="0.00"
className="h-12 text-xl text-right font-mono"
autoFocus
/>
</div>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.map((amt) => (
<Button
key={amt}
variant="outline"
className="h-11"
onClick={() => setAmountTendered(String(amt))}
>
${amt}
</Button>
))}
</div>
<Button
variant="outline"
className="w-full h-11"
onClick={() => setAmountTendered(total.toFixed(2))}
>
Exact ${total.toFixed(2)}
</Button>
{tenderedAmount >= total && (
<div className="flex justify-between text-lg font-bold text-green-600">
<span>Change</span>
<span>${changeDue.toFixed(2)}</span>
</div>
)}
</>
)}
{paymentMethod === 'check' && (
<div className="space-y-2">
<Label>Check Number</Label>
<Input
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
placeholder="Check #"
className="h-11"
autoFocus
/>
</div>
)}
{paymentMethod === 'card_present' && (
<p className="text-sm text-muted-foreground text-center py-4">
Process card payment on terminal, then confirm below.
</p>
)}
<Button
className="w-full h-12 text-base"
disabled={!canComplete || completeMutation.isPending}
onClick={() => completeMutation.mutate()}
>
{completeMutation.isPending ? 'Processing...' : `Complete ${paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'check' ? 'Check' : 'Card'} Sale`}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,72 @@
import { useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { usePOSStore } from '@/stores/pos.store'
import { currentDrawerOptions, transactionOptions } from '@/api/pos'
import { POSTopBar } from './pos-top-bar'
import { POSItemPanel } from './pos-item-panel'
import { POSCartPanel } from './pos-cart-panel'
interface Location {
id: string
name: string
}
function locationsOptions() {
return queryOptions({
queryKey: ['locations'],
queryFn: () => api.get<{ data: Location[] }>('/v1/locations'),
})
}
export function POSRegister() {
const { locationId, setLocation, currentTransactionId, drawerSessionId, setDrawerSession } = usePOSStore()
const queryClient = useQueryClient()
// Fetch locations
const { data: locationsData } = useQuery(locationsOptions())
const locations = locationsData?.data ?? []
// Auto-select first location
useEffect(() => {
if (!locationId && locations.length > 0) {
setLocation(locations[0].id)
}
}, [locationId, locations, setLocation])
// Fetch current drawer for selected location
const { data: drawer } = useQuery({
...currentDrawerOptions(locationId),
retry: false,
})
// Sync drawer session ID
useEffect(() => {
if (drawer?.id && drawer.status === 'open') {
setDrawerSession(drawer.id)
}
}, [drawer, setDrawerSession])
// Fetch current transaction
const { data: transaction } = useQuery(transactionOptions(currentTransactionId))
return (
<div className="flex flex-col h-full">
<POSTopBar
locations={locations}
locationId={locationId}
onLocationChange={setLocation}
drawer={drawer ?? null}
/>
<div className="flex flex-1 min-h-0">
<div className="w-[60%] border-r border-border overflow-hidden">
<POSItemPanel transaction={transaction ?? null} />
</div>
<div className="w-[40%] overflow-hidden">
<POSCartPanel transaction={transaction ?? null} />
</div>
</div>
</div>
)
}

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}
/>
</>
)
}

View File

@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as PosRouteImport } from './routes/pos'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
@@ -56,6 +57,11 @@ import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './rou
import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments' import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments'
import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId' import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId'
const PosRoute = PosRouteImport.update({
id: '/pos',
path: '/pos',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
path: '/login', path: '/login',
@@ -323,6 +329,7 @@ const AuthenticatedLessonsScheduleInstructorsInstructorIdRoute =
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/pos': typeof PosRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute '/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute '/settings': typeof AuthenticatedSettingsRoute
@@ -369,6 +376,7 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/pos': typeof PosRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute '/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute '/settings': typeof AuthenticatedSettingsRoute
@@ -417,6 +425,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/pos': typeof PosRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute '/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute '/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/settings': typeof AuthenticatedSettingsRoute '/_authenticated/settings': typeof AuthenticatedSettingsRoute
@@ -467,6 +476,7 @@ export interface FileRouteTypes {
fullPaths: fullPaths:
| '/' | '/'
| '/login' | '/login'
| '/pos'
| '/help' | '/help'
| '/profile' | '/profile'
| '/settings' | '/settings'
@@ -513,6 +523,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
| '/pos'
| '/help' | '/help'
| '/profile' | '/profile'
| '/settings' | '/settings'
@@ -560,6 +571,7 @@ export interface FileRouteTypes {
| '__root__' | '__root__'
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/pos'
| '/_authenticated/help' | '/_authenticated/help'
| '/_authenticated/profile' | '/_authenticated/profile'
| '/_authenticated/settings' | '/_authenticated/settings'
@@ -609,10 +621,18 @@ export interface FileRouteTypes {
export interface RootRouteChildren { export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PosRoute: typeof PosRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/pos': {
id: '/pos'
path: '/pos'
fullPath: '/pos'
preLoaderRoute: typeof PosRouteImport
parentRoute: typeof rootRouteImport
}
'/login': { '/login': {
id: '/login' id: '/login'
path: '/login' path: '/login'
@@ -1069,6 +1089,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren, AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PosRoute: PosRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -8,7 +8,7 @@ import { myPermissionsOptions } from '@/api/rbac'
import { moduleListOptions } from '@/api/modules' import { moduleListOptions } from '@/api/modules'
import { Avatar } from '@/components/shared/avatar-upload' import { Avatar } from '@/components/shared/avatar-upload'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck } from 'lucide-react' import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck, ShoppingCart } from 'lucide-react'
export const Route = createFileRoute('/_authenticated')({ export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => { beforeLoad: () => {
@@ -145,6 +145,7 @@ function AuthenticatedLayout() {
const canViewLessons = !permissionsLoaded || hasPermission('lessons.view') const canViewLessons = !permissionsLoaded || hasPermission('lessons.view')
const canViewInventory = !permissionsLoaded || hasPermission('inventory.view') const canViewInventory = !permissionsLoaded || hasPermission('inventory.view')
const canViewUsers = !permissionsLoaded || hasPermission('users.view') const canViewUsers = !permissionsLoaded || hasPermission('users.view')
const canViewPOS = !permissionsLoaded || hasPermission('pos.view')
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
@@ -173,6 +174,11 @@ function AuthenticatedLayout() {
{/* Scrollable nav links */} {/* Scrollable nav links */}
<div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin"> <div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin">
{isModuleEnabled('pos') && canViewPOS && (
<div className="mb-2">
<NavLink to="/pos" icon={<ShoppingCart className="h-4 w-4" />} label="Point of Sale" collapsed={collapsed} />
</div>
)}
{canViewAccounts && ( {canViewAccounts && (
<NavGroup label="Customers" collapsed={collapsed}> <NavGroup label="Customers" collapsed={collapsed}>
<NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" collapsed={collapsed} /> <NavLink to="/accounts" icon={<Users className="h-4 w-4" />} label="Accounts" collapsed={collapsed} />

View File

@@ -0,0 +1,21 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store'
import { POSRegister } from '@/components/pos/pos-register'
export const Route = createFileRoute('/pos')({
beforeLoad: () => {
const { token } = useAuthStore.getState()
if (!token) {
throw redirect({ to: '/login' })
}
},
component: POSPage,
})
function POSPage() {
return (
<div className="h-screen w-screen overflow-hidden bg-background text-foreground">
<POSRegister />
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { create } from 'zustand'
interface POSState {
currentTransactionId: string | null
locationId: string | null
drawerSessionId: string | null
setTransaction: (id: string | null) => void
setLocation: (id: string) => void
setDrawerSession: (id: string | null) => void
reset: () => void
}
export const usePOSStore = create<POSState>((set) => ({
currentTransactionId: null,
locationId: null,
drawerSessionId: null,
setTransaction: (id) => set({ currentTransactionId: id }),
setLocation: (id) => set({ locationId: id }),
setDrawerSession: (id) => set({ drawerSessionId: id }),
reset: () => set({ currentTransactionId: null }),
}))