Merge pull request 'feat: POS register screen with touch-optimized layout' (#6) from feature/pos-register into main
Some checks failed
Build & Release / build (push) Failing after 34s
Some checks failed
Build & Release / build (push) Failing after 34s
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
163
packages/admin/src/api/pos.ts
Normal file
163
packages/admin/src/api/pos.ts
Normal 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
|
||||||
|
price: 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: { page: number; limit: number; total: number; totalPages: number } }>('/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}`),
|
||||||
|
}
|
||||||
187
packages/admin/src/components/pos/pos-cart-panel.tsx
Normal file
187
packages/admin/src/components/pos/pos-cart-panel.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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 drawerSessionId = usePOSStore((s) => s.drawerSessionId)
|
||||||
|
const drawerOpen = !!drawerSessionId
|
||||||
|
|
||||||
|
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 space-y-2">
|
||||||
|
{!drawerOpen && hasItems && (
|
||||||
|
<p className="text-xs text-destructive text-center">Open the drawer before accepting payment</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
className="h-12 text-sm gap-2"
|
||||||
|
disabled={!hasItems || !isPending || !drawerOpen}
|
||||||
|
onClick={() => setPaymentMethod('cash')}
|
||||||
|
>
|
||||||
|
<Banknote className="h-4 w-4" />
|
||||||
|
Cash
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-12 text-sm gap-2"
|
||||||
|
disabled={!hasItems || !isPending || !drawerOpen}
|
||||||
|
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 || !drawerOpen}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment dialog */}
|
||||||
|
{paymentMethod && transaction && (
|
||||||
|
<POSPaymentDialog
|
||||||
|
open={!!paymentMethod}
|
||||||
|
onOpenChange={(open) => { if (!open) setPaymentMethod(null) }}
|
||||||
|
paymentMethod={paymentMethod}
|
||||||
|
transaction={transaction}
|
||||||
|
onComplete={handlePaymentComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal file
139
packages/admin/src/components/pos/pos-drawer-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
266
packages/admin/src/components/pos/pos-item-panel.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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: _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.price ?? '0'),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
const txnId = usePOSStore.getState().currentTransactionId
|
||||||
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
|
||||||
|
},
|
||||||
|
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: () => {
|
||||||
|
const txnId = usePOSStore.getState().currentTransactionId
|
||||||
|
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
|
||||||
|
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.price ?? '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.price ?? '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>
|
||||||
|
)
|
||||||
|
}
|
||||||
202
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal file
202
packages/admin/src/components/pos/pos-payment-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
packages/admin/src/components/pos/pos-register.tsx
Normal file
71
packages/admin/src/components/pos/pos-register.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useQuery } 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, setDrawerSession } = usePOSStore()
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
packages/admin/src/components/pos/pos-top-bar.tsx
Normal file
91
packages/admin/src/components/pos/pos-top-bar.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Link, useRouter } from '@tanstack/react-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth.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 — ${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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,12 +23,12 @@ export function usePagination() {
|
|||||||
|
|
||||||
function setParams(updates: Partial<PaginationSearch>) {
|
function setParams(updates: Partial<PaginationSearch>) {
|
||||||
navigate({
|
navigate({
|
||||||
search: ((prev: PaginationSearch) => ({
|
search: ((prev: Record<string, unknown>) => ({
|
||||||
...prev,
|
...prev,
|
||||||
...updates,
|
...updates,
|
||||||
// Reset to page 1 when search or sort changes
|
// Reset to page 1 when search or sort changes
|
||||||
page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? prev.page),
|
page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? (prev as PaginationSearch).page),
|
||||||
})) as any,
|
})) as (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
@@ -67,7 +67,7 @@ function NavLink({ to, icon, label, collapsed }: { to: string; icon: React.React
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to as '/accounts'}
|
to={to as '/accounts'}
|
||||||
search={{} as any}
|
search={{} as Record<string, unknown>}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
|
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
|
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
|
||||||
title={collapsed ? label : undefined}
|
title={collapsed ? label : undefined}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function statusBadge(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: Column<Enrollment & { memberName?: string }>[] = [
|
const columns: Column<Enrollment & { memberName?: string }>[] = [
|
||||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{e.memberName ?? e.memberId}</span> },
|
||||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||||
@@ -41,7 +41,7 @@ function AccountEnrollmentsTab() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">{data?.pagination.total ?? 0} enrollment(s)</p>
|
<p className="text-sm text-muted-foreground">{data?.pagination.total ?? 0} enrollment(s)</p>
|
||||||
{hasPermission('lessons.edit') && (
|
{hasPermission('lessons.edit') && (
|
||||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as Record<string, unknown> })}>
|
||||||
<Plus className="h-4 w-4 mr-1" />Enroll a Member
|
<Plus className="h-4 w-4 mr-1" />Enroll a Member
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -55,7 +55,7 @@ function AccountEnrollmentsTab() {
|
|||||||
total={data?.data?.length ?? 0}
|
total={data?.data?.length ?? 0}
|
||||||
onPageChange={() => {}}
|
onPageChange={() => {}}
|
||||||
onSort={() => {}}
|
onSort={() => {}}
|
||||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ function MembersTab() {
|
|||||||
<DropdownMenuItem onClick={() => navigate({
|
<DropdownMenuItem onClick={() => navigate({
|
||||||
to: '/members/$memberId',
|
to: '/members/$memberId',
|
||||||
params: { memberId: m.id },
|
params: { memberId: m.id },
|
||||||
search: {} as any,
|
search: {} as Record<string, unknown>,
|
||||||
})}>
|
})}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function FileManagerPage() {
|
|||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
toast.error(`Upload failed: ${(err as any).error?.message ?? file.name}`)
|
toast.error(`Upload failed: ${(err as { error?: { message?: string } }).error?.message ?? file.name}`)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(`Upload failed: ${file.name}`)
|
toast.error(`Upload failed: ${file.name}`)
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
|
|||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/')({
|
export const Route = createFileRoute('/_authenticated/')({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
throw redirect({ to: '/accounts', search: {} as any })
|
throw redirect({ to: '/accounts', search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ function ProductDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setTab(t: string) {
|
function setTab(t: string) {
|
||||||
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as any })
|
navigate({ to: '/inventory/$productId', params: { productId }, search: { tab: t } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQtySave() {
|
function handleQtySave() {
|
||||||
@@ -192,7 +192,7 @@ function ProductDetailPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/inventory', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -507,12 +507,12 @@ function SuppliersTab({
|
|||||||
setAddOpen: (v: boolean) => void
|
setAddOpen: (v: boolean) => void
|
||||||
editTarget: ProductSupplier | null
|
editTarget: ProductSupplier | null
|
||||||
setEditTarget: (v: ProductSupplier | null) => void
|
setEditTarget: (v: ProductSupplier | null) => void
|
||||||
addMutation: any
|
addMutation: { mutate: (data: Record<string, unknown>) => void; isPending: boolean }
|
||||||
updateMutation: any
|
updateMutation: { mutate: (args: { id: string; data: Record<string, unknown> }) => void; isPending: boolean }
|
||||||
removeMutation: any
|
removeMutation: { mutate: (id: string) => void; isPending: boolean }
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
}) {
|
}) {
|
||||||
const { data: allSuppliersData } = useQuery(supplierListOptions({ page: 1, limit: 500, order: 'asc', sort: 'name' } as any))
|
const { data: allSuppliersData } = useQuery(supplierListOptions({ page: 1, limit: 500, order: 'asc', sort: 'name' }))
|
||||||
const allSuppliers = allSuppliersData?.data ?? []
|
const allSuppliers = allSuppliersData?.data ?? []
|
||||||
const linkedIds = new Set(linkedSuppliers.map((s) => s.supplierId))
|
const linkedIds = new Set(linkedSuppliers.map((s) => s.supplierId))
|
||||||
const availableSuppliers = allSuppliers.filter((s) => !linkedIds.has(s.id))
|
const availableSuppliers = allSuppliers.filter((s) => !linkedIds.has(s.id))
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function InventoryPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: productKeys.all })
|
queryClient.invalidateQueries({ queryKey: productKeys.all })
|
||||||
toast.success('Product created')
|
toast.success('Product created')
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as any })
|
navigate({ to: '/inventory/$productId', params: { productId: product.id }, search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -83,23 +83,23 @@ function InventoryPage() {
|
|||||||
|
|
||||||
function handleCategoryChange(v: string) {
|
function handleCategoryChange(v: string) {
|
||||||
setCategoryFilter(v === 'all' ? '' : v)
|
setCategoryFilter(v === 'all' ? '' : v)
|
||||||
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as any })
|
navigate({ to: '/inventory', search: { ...search, categoryId: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleActiveChange(v: string) {
|
function handleActiveChange(v: string) {
|
||||||
setActiveFilter(v === 'all' ? '' : v)
|
setActiveFilter(v === 'all' ? '' : v)
|
||||||
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as any })
|
navigate({ to: '/inventory', search: { ...search, isActive: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTypeChange(v: string) {
|
function handleTypeChange(v: string) {
|
||||||
setTypeFilter(v === 'all' ? '' : v)
|
setTypeFilter(v === 'all' ? '' : v)
|
||||||
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as any })
|
navigate({ to: '/inventory', search: { ...search, type: v === 'all' ? undefined : v, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLowStockChange(v: string) {
|
function handleLowStockChange(v: string) {
|
||||||
const on = v === 'true'
|
const on = v === 'true'
|
||||||
setLowStockFilter(on)
|
setLowStockFilter(on)
|
||||||
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as any })
|
navigate({ to: '/inventory', search: { ...search, lowStock: on ? 'true' : undefined, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: Column<Product>[] = [
|
const columns: Column<Product>[] = [
|
||||||
@@ -246,7 +246,7 @@ function InventoryPage() {
|
|||||||
order={params.order}
|
order={params.order}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as any })}
|
onRowClick={(p) => navigate({ to: '/inventory/$productId', params: { productId: p.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function SuppliersPage() {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>New Supplier</DialogTitle></DialogHeader>
|
<DialogHeader><DialogTitle>New Supplier</DialogTitle></DialogHeader>
|
||||||
<SupplierForm
|
<SupplierForm
|
||||||
onSubmit={supplierMutations.create.bind(null) as any}
|
onSubmit={(data) => { createMutation.mutate(data) }}
|
||||||
loading={createMutation.isPending}
|
loading={createMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useAuthStore } from '@/stores/auth.store'
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
import type { LessonSession, LessonPlan, LessonPlanTemplate } from '@/types/lesson'
|
import type { Enrollment, LessonSession, LessonPlan, LessonPlanTemplate } from '@/types/lesson'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/$enrollmentId')({
|
export const Route = createFileRoute('/_authenticated/lessons/enrollments/$enrollmentId')({
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
@@ -81,7 +81,7 @@ function EnrollmentDetailPage() {
|
|||||||
const tab = search.tab
|
const tab = search.tab
|
||||||
|
|
||||||
function setTab(t: string) {
|
function setTab(t: string) {
|
||||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } as any })
|
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: enrollment, isLoading } = useQuery(enrollmentDetailOptions(enrollmentId))
|
const { data: enrollment, isLoading } = useQuery(enrollmentDetailOptions(enrollmentId))
|
||||||
@@ -131,7 +131,7 @@ function EnrollmentDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -193,7 +193,17 @@ const BILLING_UNITS = [
|
|||||||
function DetailsTab({
|
function DetailsTab({
|
||||||
enrollment, slotLabel, lessonTypeName, instructorName,
|
enrollment, slotLabel, lessonTypeName, instructorName,
|
||||||
canEdit, onSave, saving, onStatusChange, statusChanging,
|
canEdit, onSave, saving, onStatusChange, statusChanging,
|
||||||
}: any) {
|
}: {
|
||||||
|
enrollment: Enrollment
|
||||||
|
slotLabel: string
|
||||||
|
lessonTypeName: string | undefined
|
||||||
|
instructorName: string | undefined
|
||||||
|
canEdit: boolean
|
||||||
|
onSave: (data: Record<string, unknown>) => void
|
||||||
|
saving: boolean
|
||||||
|
onStatusChange: (status: string) => void
|
||||||
|
statusChanging: boolean
|
||||||
|
}) {
|
||||||
const [rate, setRate] = useState(enrollment.rate ?? '')
|
const [rate, setRate] = useState(enrollment.rate ?? '')
|
||||||
const [billingInterval, setBillingInterval] = useState(String(enrollment.billingInterval ?? 1))
|
const [billingInterval, setBillingInterval] = useState(String(enrollment.billingInterval ?? 1))
|
||||||
const [billingUnit, setBillingUnit] = useState(enrollment.billingUnit ?? 'month')
|
const [billingUnit, setBillingUnit] = useState(enrollment.billingUnit ?? 'month')
|
||||||
@@ -334,7 +344,7 @@ function SessionsTab({ enrollmentId, onGenerate, generating }: { enrollmentId: s
|
|||||||
total={data?.data?.length ?? 0}
|
total={data?.data?.length ?? 0}
|
||||||
onPageChange={() => {}}
|
onPageChange={() => {}}
|
||||||
onSort={() => {}}
|
onSort={() => {}}
|
||||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -373,7 +383,7 @@ function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: stri
|
|||||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
|
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
|
||||||
toast.success('Plan created from template')
|
toast.success('Plan created from template')
|
||||||
setTemplatePickerOpen(false)
|
setTemplatePickerOpen(false)
|
||||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -391,7 +401,7 @@ function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: stri
|
|||||||
{Math.round(activePlan.progress)}% complete
|
{Math.round(activePlan.progress)}% complete
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} as any })}>
|
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} as Record<string, unknown> })}>
|
||||||
View Plan
|
View Plan
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ function statusBadge(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: Column<Enrollment & { memberName?: string; instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
const columns: Column<Enrollment & { memberName?: string; instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
||||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{e.memberName ?? e.memberId}</span> },
|
||||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{e.instructorName ?? e.instructorId}</> },
|
||||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{e.slotInfo ?? '—'}</> },
|
||||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||||
@@ -72,7 +72,7 @@ function EnrollmentsListPage() {
|
|||||||
function handleStatusChange(v: string) {
|
function handleStatusChange(v: string) {
|
||||||
const s = v === 'all' ? '' : v
|
const s = v === 'all' ? '' : v
|
||||||
setStatusFilter(s)
|
setStatusFilter(s)
|
||||||
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as any })
|
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,7 +80,7 @@ function EnrollmentsListPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Enrollments</h1>
|
<h1 className="text-2xl font-bold">Enrollments</h1>
|
||||||
{hasPermission('lessons.edit') && (
|
{hasPermission('lessons.edit') && (
|
||||||
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as Record<string, unknown> })}>
|
||||||
<Plus className="mr-2 h-4 w-4" />New Enrollment
|
<Plus className="mr-2 h-4 w-4" />New Enrollment
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -125,7 +125,7 @@ function EnrollmentsListPage() {
|
|||||||
order={params.order}
|
order={params.order}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function NewEnrollmentPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: (enrollment) => {
|
onSuccess: (enrollment) => {
|
||||||
toast.success('Enrollment created')
|
toast.success('Enrollment created')
|
||||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: {} as any })
|
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -141,7 +141,7 @@ function NewEnrollmentPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">New Enrollment</h1>
|
<h1 className="text-2xl font-bold">New Enrollment</h1>
|
||||||
@@ -282,7 +282,7 @@ function NewEnrollmentPage() {
|
|||||||
<Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg">
|
<Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg">
|
||||||
{mutation.isPending ? 'Creating...' : 'Create Enrollment'}
|
{mutation.isPending ? 'Creating...' : 'Create Enrollment'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as Record<string, unknown> })}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function LessonPlanDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function LessonPlansPage() {
|
|||||||
order={params.order}
|
order={params.order}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as any })}
|
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function ScheduleHubPage() {
|
|||||||
const canAdmin = hasPermission('lessons.admin')
|
const canAdmin = hasPermission('lessons.admin')
|
||||||
|
|
||||||
function setTab(t: string) {
|
function setTab(t: string) {
|
||||||
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as any })
|
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +90,7 @@ const instructorColumns: Column<Instructor>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function InstructorsTab({ canAdmin, search: _search }: { canAdmin: boolean; search: any }) {
|
function InstructorsTab({ canAdmin, search: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { params, setPage, setSearch, setSort } = usePagination()
|
const { params, setPage, setSearch, setSort } = usePagination()
|
||||||
@@ -152,7 +152,7 @@ function InstructorsTab({ canAdmin, search: _search }: { canAdmin: boolean; sear
|
|||||||
order={params.order}
|
order={params.order}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as any })}
|
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -169,7 +169,7 @@ const lessonTypeColumns: Column<LessonType>[] = [
|
|||||||
{ key: 'is_active', header: 'Status', render: (lt) => <Badge variant={lt.isActive ? 'default' : 'secondary'}>{lt.isActive ? 'Active' : 'Inactive'}</Badge> },
|
{ key: 'is_active', header: 'Status', render: (lt) => <Badge variant={lt.isActive ? 'default' : 'secondary'}>{lt.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||||
]
|
]
|
||||||
|
|
||||||
function LessonTypesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: any }) {
|
function LessonTypesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { params, setPage, setSearch, setSort } = usePagination()
|
const { params, setPage, setSearch, setSort } = usePagination()
|
||||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||||
@@ -215,8 +215,8 @@ function LessonTypesTab({ canAdmin, search: _search }: { canAdmin: boolean; sear
|
|||||||
const columnsWithActions: Column<LessonType>[] = [
|
const columnsWithActions: Column<LessonType>[] = [
|
||||||
...lessonTypeColumns,
|
...lessonTypeColumns,
|
||||||
...(canAdmin ? [{
|
...(canAdmin ? [{
|
||||||
key: 'actions' as any,
|
key: 'actions',
|
||||||
header: '' as any,
|
header: '',
|
||||||
render: (lt: LessonType) => (
|
render: (lt: LessonType) => (
|
||||||
<div className="flex gap-1 justify-end">
|
<div className="flex gap-1 justify-end">
|
||||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(lt) }}>Edit</Button>
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(lt) }}>Edit</Button>
|
||||||
@@ -298,7 +298,7 @@ const gradingScaleColumns: Column<GradingScale>[] = [
|
|||||||
{ key: 'is_active', header: 'Status', render: (gs) => <Badge variant={gs.isActive ? 'default' : 'secondary'}>{gs.isActive ? 'Active' : 'Inactive'}</Badge> },
|
{ key: 'is_active', header: 'Status', render: (gs) => <Badge variant={gs.isActive ? 'default' : 'secondary'}>{gs.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||||
]
|
]
|
||||||
|
|
||||||
function GradingScalesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: any }) {
|
function GradingScalesTab({ canAdmin, search: _search }: { canAdmin: boolean; search: Record<string, unknown> }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { params, setPage, setSort } = usePagination()
|
const { params, setPage, setSort } = usePagination()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
@@ -327,8 +327,8 @@ function GradingScalesTab({ canAdmin, search: _search }: { canAdmin: boolean; se
|
|||||||
const columnsWithActions: Column<GradingScale>[] = [
|
const columnsWithActions: Column<GradingScale>[] = [
|
||||||
...gradingScaleColumns,
|
...gradingScaleColumns,
|
||||||
...(canAdmin ? [{
|
...(canAdmin ? [{
|
||||||
key: 'actions' as any,
|
key: 'actions',
|
||||||
header: '' as any,
|
header: '',
|
||||||
render: (gs: GradingScale) => (
|
render: (gs: GradingScale) => (
|
||||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(gs.id) }}>
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(gs.id) }}>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function InstructorDetailPage() {
|
|||||||
const tab = search.tab
|
const tab = search.tab
|
||||||
|
|
||||||
function setTab(t: string) {
|
function setTab(t: string) {
|
||||||
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as any })
|
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId))
|
const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId))
|
||||||
@@ -62,7 +62,7 @@ function InstructorDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { ArrowLeft, CheckSquare, Square } from 'lucide-react'
|
import { ArrowLeft, CheckSquare, Square } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useAuthStore } from '@/stores/auth.store'
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
import type { LessonPlan, LessonPlanSection } from '@/types/lesson'
|
import type { LessonPlan, LessonPlanSection, LessonSession } from '@/types/lesson'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/lessons/sessions/$sessionId')({
|
export const Route = createFileRoute('/_authenticated/lessons/sessions/$sessionId')({
|
||||||
component: SessionDetailPage,
|
component: SessionDetailPage,
|
||||||
@@ -126,7 +126,7 @@ function SessionDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -137,7 +137,7 @@ function SessionDetailPage() {
|
|||||||
<Link
|
<Link
|
||||||
to="/lessons/enrollments/$enrollmentId"
|
to="/lessons/enrollments/$enrollmentId"
|
||||||
params={{ enrollmentId: enrollment.id }}
|
params={{ enrollmentId: enrollment.id }}
|
||||||
search={{} as any}
|
search={{} as Record<string, unknown>}
|
||||||
className="text-sm text-primary hover:underline"
|
className="text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
View Enrollment
|
View Enrollment
|
||||||
@@ -209,7 +209,12 @@ function SessionDetailPage() {
|
|||||||
|
|
||||||
// ─── Notes Card ───────────────────────────────────────────────────────────────
|
// ─── Notes Card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NotesCard({ session, canEdit, onSave, saving }: any) {
|
function NotesCard({ session, canEdit, onSave, saving }: {
|
||||||
|
session: LessonSession
|
||||||
|
canEdit: boolean
|
||||||
|
onSave: (data: Record<string, unknown>) => void
|
||||||
|
saving: boolean
|
||||||
|
}) {
|
||||||
const [instructorNotes, setInstructorNotes] = useState(session.instructorNotes ?? '')
|
const [instructorNotes, setInstructorNotes] = useState(session.instructorNotes ?? '')
|
||||||
const [memberNotes, setMemberNotes] = useState(session.memberNotes ?? '')
|
const [memberNotes, setMemberNotes] = useState(session.memberNotes ?? '')
|
||||||
const [homeworkAssigned, setHomeworkAssigned] = useState(session.homeworkAssigned ?? '')
|
const [homeworkAssigned, setHomeworkAssigned] = useState(session.homeworkAssigned ?? '')
|
||||||
|
|||||||
@@ -92,13 +92,13 @@ function SessionsPage() {
|
|||||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 })
|
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 })
|
||||||
|
|
||||||
function setView(v: 'list' | 'week') {
|
function setView(v: 'list' | 'week') {
|
||||||
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } as any })
|
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStatusChange(v: string) {
|
function handleStatusChange(v: string) {
|
||||||
const s = v === 'all' ? '' : v
|
const s = v === 'all' ? '' : v
|
||||||
setStatusFilter(s)
|
setStatusFilter(s)
|
||||||
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } as any })
|
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
// List query
|
// List query
|
||||||
@@ -189,7 +189,7 @@ function SessionsPage() {
|
|||||||
order={params.order}
|
order={params.order}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -249,7 +249,7 @@ function SessionsPage() {
|
|||||||
{daySessions.map((s) => (
|
{daySessions.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as Record<string, unknown> })}
|
||||||
className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`}
|
className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`}
|
||||||
>
|
>
|
||||||
<p className="font-semibold">{formatTime(s.scheduledTime)}</p>
|
<p className="font-semibold">{formatTime(s.scheduledTime)}</p>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { ArrowLeft, Search, X, Zap } from 'lucide-react'
|
import { ArrowLeft, Search, X, Zap } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useAuthStore } from '@/stores/auth.store'
|
import { useAuthStore } from '@/stores/auth.store'
|
||||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
import type { Enrollment, LessonPlanTemplate } from '@/types/lesson'
|
||||||
import type { MemberWithAccount } from '@/api/members'
|
import type { MemberWithAccount } from '@/api/members'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/lessons/templates/$templateId')({
|
export const Route = createFileRoute('/_authenticated/lessons/templates/$templateId')({
|
||||||
@@ -42,7 +42,7 @@ function TemplateDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -93,7 +93,7 @@ function TemplateDetailPage() {
|
|||||||
|
|
||||||
// ─── Edit Form ────────────────────────────────────────────────────────────────
|
// ─── Edit Form ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: any }) {
|
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: ReturnType<typeof useQueryClient> }) {
|
||||||
const [name, setName] = useState(template.name)
|
const [name, setName] = useState(template.name)
|
||||||
const [description, setDescription] = useState(template.description ?? '')
|
const [description, setDescription] = useState(template.description ?? '')
|
||||||
const [instrument, setInstrument] = useState(template.instrument ?? '')
|
const [instrument, setInstrument] = useState(template.instrument ?? '')
|
||||||
@@ -218,7 +218,7 @@ function InstantiateDialog({ template, templateId, open, onClose }: {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (plan) => {
|
onSuccess: (plan) => {
|
||||||
toast.success('Plan created from template')
|
toast.success('Plan created from template')
|
||||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -293,7 +293,7 @@ function InstantiateDialog({ template, templateId, open, onClose }: {
|
|||||||
<SelectTrigger><SelectValue placeholder="Not linked to enrollment" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Not linked to enrollment" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">Not linked to enrollment</SelectItem>
|
<SelectItem value="none">Not linked to enrollment</SelectItem>
|
||||||
{enrollments.map((e: any) => (
|
{enrollments.map((e: Enrollment) => (
|
||||||
<SelectItem key={e.id} value={e.id}>Enrollment {e.id.slice(-6)}</SelectItem>
|
<SelectItem key={e.id} value={e.id}>Enrollment {e.id.slice(-6)}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ function TemplatesListPage() {
|
|||||||
const columnsWithActions: Column<LessonPlanTemplate>[] = [
|
const columnsWithActions: Column<LessonPlanTemplate>[] = [
|
||||||
...columns,
|
...columns,
|
||||||
...(canAdmin ? [{
|
...(canAdmin ? [{
|
||||||
key: 'actions' as any,
|
key: 'actions',
|
||||||
header: '' as any,
|
header: '',
|
||||||
render: (t: LessonPlanTemplate) => (
|
render: (t: LessonPlanTemplate) => (
|
||||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
@@ -96,7 +96,7 @@ function TemplatesListPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
|
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
|
||||||
{canAdmin && (
|
{canAdmin && (
|
||||||
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as any })}>
|
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as Record<string, unknown> })}>
|
||||||
<Plus className="mr-2 h-4 w-4" />New Template
|
<Plus className="mr-2 h-4 w-4" />New Template
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -126,7 +126,7 @@ function TemplatesListPage() {
|
|||||||
order={params.order}
|
order={params.order}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as any })}
|
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function NewTemplatePage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (template) => {
|
onSuccess: (template) => {
|
||||||
toast.success('Template created')
|
toast.success('Template created')
|
||||||
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as any })
|
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -63,7 +63,7 @@ function NewTemplatePage() {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
|
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">New Template</h1>
|
<h1 className="text-2xl font-bold">New Template</h1>
|
||||||
@@ -112,7 +112,7 @@ function NewTemplatePage() {
|
|||||||
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
|
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
|
||||||
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as Record<string, unknown> })}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ function statusBadge(status: string) {
|
|||||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||||
}
|
}
|
||||||
|
|
||||||
const enrollmentColumns: Column<Enrollment>[] = [
|
const enrollmentColumns: Column<Enrollment & { instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
||||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{e.instructorName ?? e.instructorId}</> },
|
||||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{e.slotInfo ?? '—'}</> },
|
||||||
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{(e as any).lessonTypeName ?? '—'}</> },
|
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{e.lessonTypeName ?? '—'}</> },
|
||||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate} / ${e.billingInterval} ${e.billingUnit}` : <span className="text-muted-foreground">—</span>}</> },
|
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate} / ${e.billingInterval} ${e.billingUnit}` : <span className="text-muted-foreground">—</span>}</> },
|
||||||
]
|
]
|
||||||
@@ -161,7 +161,7 @@ function MemberDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setTab(t: string) {
|
function setTab(t: string) {
|
||||||
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as any })
|
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -188,7 +188,7 @@ function MemberDetailPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -293,7 +293,7 @@ function MemberDetailPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p>
|
<p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p>
|
||||||
{hasPermission('lessons.edit') && (
|
{hasPermission('lessons.edit') && (
|
||||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId } as any })}>
|
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId } as Record<string, unknown> })}>
|
||||||
<Plus className="h-4 w-4 mr-1" />Enroll
|
<Plus className="h-4 w-4 mr-1" />Enroll
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -307,7 +307,7 @@ function MemberDetailPage() {
|
|||||||
total={enrollmentsData?.data?.length ?? 0}
|
total={enrollmentsData?.data?.length ?? 0}
|
||||||
onPageChange={() => {}}
|
onPageChange={() => {}}
|
||||||
onSort={() => {}}
|
onSort={() => {}}
|
||||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function MembersListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as any })}>
|
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as Record<string, unknown> })}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -134,7 +134,7 @@ function MembersListPage() {
|
|||||||
order={params.order}
|
order={params.order}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onSort={setSort}
|
onSort={setSort}
|
||||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as any })}
|
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as Record<string, unknown> })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -96,12 +96,12 @@ function RepairBatchDetailPage() {
|
|||||||
const totalActual = tickets.reduce((sum, t) => sum + (t.actualCost ? parseFloat(t.actualCost) : 0), 0)
|
const totalActual = tickets.reduce((sum, t) => sum + (t.actualCost ? parseFloat(t.actualCost) : 0), 0)
|
||||||
|
|
||||||
function handleTicketClick(ticket: RepairTicket) {
|
function handleTicketClick(ticket: RepairTicket) {
|
||||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any })
|
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddRepair() {
|
function handleAddRepair() {
|
||||||
// Navigate to new repair with batch and account pre-linked
|
// Navigate to new repair with batch and account pre-linked
|
||||||
navigate({ to: '/repairs/new', search: { batchId, batchName: batch!.batchNumber ?? '', accountId: batch!.accountId, contactName: batch!.contactName ?? '' } as any })
|
navigate({ to: '/repairs/new', search: { batchId, batchName: batch!.batchNumber ?? '', accountId: batch!.accountId, contactName: batch!.contactName ?? '' } as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateBatchPdf() {
|
async function generateBatchPdf() {
|
||||||
@@ -233,7 +233,7 @@ function RepairBatchDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl">
|
<div className="space-y-6 max-w-5xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function RepairBatchesListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRowClick(batch: RepairBatch) {
|
function handleRowClick(batch: RepairBatch) {
|
||||||
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as any })
|
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function NewRepairBatchPage() {
|
|||||||
mutationFn: repairBatchMutations.create,
|
mutationFn: repairBatchMutations.create,
|
||||||
onSuccess: (batch) => {
|
onSuccess: (batch) => {
|
||||||
toast.success('Repair batch created')
|
toast.success('Repair batch created')
|
||||||
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as any })
|
navigate({ to: '/repair-batches/$batchId', params: { batchId: batch.id }, search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -78,7 +78,7 @@ function NewRepairBatchPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">New Repair Batch</h1>
|
<h1 className="text-2xl font-bold">New Repair Batch</h1>
|
||||||
@@ -176,7 +176,7 @@ function NewRepairBatchPage() {
|
|||||||
<Button type="submit" disabled={mutation.isPending} size="lg">
|
<Button type="submit" disabled={mutation.isPending} size="lg">
|
||||||
{mutation.isPending ? 'Creating...' : 'Create Batch'}
|
{mutation.isPending ? 'Creating...' : 'Create Batch'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repair-batches', search: {} as any })}>
|
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repair-batches', search: {} as Record<string, unknown> })}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ function RepairTicketDetailPage() {
|
|||||||
<div className="space-y-4 max-w-5xl">
|
<div className="space-y-4 max-w-5xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function RepairsListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRowClick(ticket: RepairTicket) {
|
function handleRowClick(ticket: RepairTicket) {
|
||||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any })
|
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +137,7 @@ function RepairsListPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Repairs</h1>
|
<h1 className="text-2xl font-bold">Repairs</h1>
|
||||||
{hasPermission('repairs.edit') && (
|
{hasPermission('repairs.edit') && (
|
||||||
<Button onClick={() => navigate({ to: '/repairs/new', search: {} as any })}>
|
<Button onClick={() => navigate({ to: '/repairs/new', search: {} as Record<string, unknown> })}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New Repair
|
New Repair
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function NewRepairPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
toast.success('Repair ticket created')
|
toast.success('Repair ticket created')
|
||||||
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as any })
|
navigate({ to: '/repairs/$ticketId', params: { ticketId: ticket.id }, search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -210,7 +210,7 @@ function NewRepairPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">New Repair Ticket</h1>
|
<h1 className="text-2xl font-bold">New Repair Ticket</h1>
|
||||||
@@ -314,7 +314,7 @@ function NewRepairPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Condition at Intake</Label>
|
<Label>Condition at Intake</Label>
|
||||||
<Select onValueChange={(v) => setValue('conditionIn', v as any)}>
|
<Select onValueChange={(v) => setValue('conditionIn', v as 'excellent' | 'good' | 'fair' | 'poor')}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select condition" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select condition" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="excellent">Excellent</SelectItem>
|
<SelectItem value="excellent">Excellent</SelectItem>
|
||||||
@@ -486,7 +486,7 @@ function NewRepairPage() {
|
|||||||
<Button type="submit" disabled={mutation.isPending} size="lg">
|
<Button type="submit" disabled={mutation.isPending} size="lg">
|
||||||
{mutation.isPending ? 'Creating...' : 'Create Ticket'}
|
{mutation.isPending ? 'Creating...' : 'Create Ticket'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repairs', search: {} as any })}>
|
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/repairs', search: {} as Record<string, unknown> })}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function RoleDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/roles', search: {} as any })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -177,7 +177,7 @@ function RoleDetailPage() {
|
|||||||
<Button onClick={handleSave} disabled={updateMutation.isPending}>
|
<Button onClick={handleSave} disabled={updateMutation.isPending}>
|
||||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as any })}>
|
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function NewRolePage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: rbacKeys.roles })
|
queryClient.invalidateQueries({ queryKey: rbacKeys.roles })
|
||||||
toast.success('Role created')
|
toast.success('Role created')
|
||||||
navigate({ to: '/roles', search: {} as any })
|
navigate({ to: '/roles', search: {} as Record<string, unknown> })
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
@@ -153,7 +153,7 @@ function NewRolePage() {
|
|||||||
<Button onClick={handleSubmit} disabled={mutation.isPending}>
|
<Button onClick={handleSubmit} disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? 'Creating...' : 'Create Role'}
|
{mutation.isPending ? 'Creating...' : 'Create Role'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as any })}>
|
<Button variant="secondary" onClick={() => navigate({ to: '/roles', search: {} as Record<string, unknown> })}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const Route = createFileRoute('/login')({
|
|||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
const { token } = useAuthStore.getState()
|
const { token } = useAuthStore.getState()
|
||||||
if (token) {
|
if (token) {
|
||||||
throw redirect({ to: '/accounts', search: {} as any })
|
throw redirect({ to: '/accounts', search: {} as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
@@ -30,7 +30,7 @@ function LoginPage() {
|
|||||||
const res = await login(email, password)
|
const res = await login(email, password)
|
||||||
setAuth(res.token, res.user)
|
setAuth(res.token, res.user)
|
||||||
await router.invalidate()
|
await router.invalidate()
|
||||||
await router.navigate({ to: '/accounts', search: {} as any, replace: true })
|
await router.navigate({ to: '/accounts', search: {} as Record<string, unknown>, replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Login failed')
|
setError(err instanceof Error ? err.message : 'Login failed')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
21
packages/admin/src/routes/pos.tsx
Normal file
21
packages/admin/src/routes/pos.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
packages/admin/src/stores/pos.store.ts
Normal file
21
packages/admin/src/stores/pos.store.ts
Normal 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 }),
|
||||||
|
}))
|
||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
allowedHosts: ['dev.lunarfront.tech'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1': {
|
'/v1': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
|
|||||||
@@ -273,6 +273,36 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
|
|
||||||
// ─── Complete Transaction ──────────────────────────────────────────────────
|
// ─── Complete Transaction ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
t.test('rejects completing transaction without open drawer', { tags: ['transactions', 'complete', 'validation', 'drawer'] }, async () => {
|
||||||
|
// Ensure no drawer is open at LOCATION_ID
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'No Drawer Item',
|
||||||
|
qty: 1,
|
||||||
|
unitPrice: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
|
||||||
|
paymentMethod: 'cash',
|
||||||
|
amountTendered: 20,
|
||||||
|
})
|
||||||
|
t.assert.status(res, 400)
|
||||||
|
|
||||||
|
// Void to clean up
|
||||||
|
await t.api.post(`/v1/transactions/${txn.data.id}/void`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open a drawer for the remaining complete tests
|
||||||
|
t.test('opens drawer for complete tests', { tags: ['transactions', 'complete', 'setup'] }, async () => {
|
||||||
|
const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
||||||
|
t.assert.status(res, 201)
|
||||||
|
})
|
||||||
|
|
||||||
t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => {
|
t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => {
|
||||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
|
||||||
@@ -427,6 +457,18 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
|
|
||||||
// ─── Cash Rounding ─────────────────────────────────────────────────────────
|
// ─── Cash Rounding ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Close the LOCATION_ID drawer and open one at ROUNDING_LOCATION_ID
|
||||||
|
t.test('setup drawer for rounding tests', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
||||||
|
// Close drawer at LOCATION_ID
|
||||||
|
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: 200 })
|
||||||
|
}
|
||||||
|
// Open drawer at ROUNDING_LOCATION_ID
|
||||||
|
const res = await t.api.post('/v1/drawer/open', { locationId: ROUNDING_LOCATION_ID, openingBalance: 200 })
|
||||||
|
t.assert.status(res, 201)
|
||||||
|
})
|
||||||
|
|
||||||
t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
|
t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
|
||||||
// Create transaction at the rounding-enabled location
|
// Create transaction at the rounding-enabled location
|
||||||
const txn = await t.api.post('/v1/transactions', {
|
const txn = await t.api.post('/v1/transactions', {
|
||||||
@@ -484,6 +526,10 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => {
|
||||||
|
// Open drawer at LOCATION_ID for this test
|
||||||
|
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', {
|
const txn = await t.api.post('/v1/transactions', {
|
||||||
transactionType: 'sale',
|
transactionType: 'sale',
|
||||||
locationId: LOCATION_ID,
|
locationId: LOCATION_ID,
|
||||||
@@ -500,6 +546,17 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
})
|
})
|
||||||
t.assert.status(res, 200)
|
t.assert.status(res, 200)
|
||||||
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close rounding location drawer
|
||||||
|
t.test('cleanup rounding drawer', { tags: ['transactions', 'rounding', 'setup'] }, async () => {
|
||||||
|
const current = await t.api.get('/v1/drawer/current', { locationId: ROUNDING_LOCATION_ID })
|
||||||
|
if (current.status === 200 && current.data.id) {
|
||||||
|
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Full POS Flow ────────────────────────────────────────────────────────
|
// ─── Full POS Flow ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import postgres from 'postgres'
|
import postgres from 'postgres'
|
||||||
|
|
||||||
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
||||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
|
||||||
const sql = postgres(DB_URL)
|
const sql = postgres(DB_URL)
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ async function seed() {
|
|||||||
const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}`
|
const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}`
|
||||||
if (!company) {
|
if (!company) {
|
||||||
await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Demo Store', 'America/Chicago')`
|
await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Demo Store', 'America/Chicago')`
|
||||||
await sql`INSERT INTO location (id, name) VALUES ('a0000000-0000-0000-0000-000000000002', 'Main Store')`
|
await sql`INSERT INTO location (id, name, tax_rate, service_tax_rate) VALUES ('a0000000-0000-4000-8000-000000000002', 'Main Store', '0.0825', '0.0825')`
|
||||||
console.log(' Created company and location')
|
console.log(' Created company and location')
|
||||||
|
|
||||||
// Seed RBAC
|
// Seed RBAC
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import postgres from 'postgres'
|
import postgres from 'postgres'
|
||||||
|
|
||||||
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront'
|
||||||
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001'
|
const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
|
||||||
const sql = postgres(DB_URL)
|
const sql = postgres(DB_URL)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
transactionLineItems,
|
transactionLineItems,
|
||||||
discountAudits,
|
discountAudits,
|
||||||
discounts,
|
discounts,
|
||||||
|
drawerSessions,
|
||||||
} from '../db/schema/pos.js'
|
} from '../db/schema/pos.js'
|
||||||
import { products, inventoryUnits } from '../db/schema/inventory.js'
|
import { products, inventoryUnits } from '../db/schema/inventory.js'
|
||||||
import { companies, locations } from '../db/schema/stores.js'
|
import { companies, locations } from '../db/schema/stores.js'
|
||||||
@@ -231,6 +232,18 @@ export const TransactionService = {
|
|||||||
if (!txn) throw new NotFoundError('Transaction')
|
if (!txn) throw new NotFoundError('Transaction')
|
||||||
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
|
||||||
|
if (txn.locationId) {
|
||||||
|
const [openDrawer] = await db
|
||||||
|
.select({ id: drawerSessions.id })
|
||||||
|
.from(drawerSessions)
|
||||||
|
.where(and(eq(drawerSessions.locationId, txn.locationId), eq(drawerSessions.status, 'open')))
|
||||||
|
.limit(1)
|
||||||
|
if (!openDrawer) {
|
||||||
|
throw new ValidationError('Cannot complete transaction without an open drawer at this location')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate cash payment (with optional nickel rounding)
|
// Validate cash payment (with optional nickel rounding)
|
||||||
let changeGiven: string | undefined
|
let changeGiven: string | undefined
|
||||||
let roundingAdjustment = 0
|
let roundingAdjustment = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user