diff --git a/packages/admin/src/api/pos.ts b/packages/admin/src/api/pos.ts new file mode 100644 index 0000000..3c525aa --- /dev/null +++ b/packages/admin/src/api/pos.ts @@ -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 | null + status: string + notes: string | null + openedAt: string + closedAt: string | null +} + +export interface Discount { + id: string + name: string + discountType: string + discountValue: string + appliesTo: string + requiresApprovalAbove: string | null + isActive: boolean +} + +export interface Product { + id: string + name: string + sku: string | null + upc: string | null + description: string | null + sellingPrice: string | null + costPrice: string | null + qtyOnHand: number | null + taxCategory: string + isSerialized: boolean + isActive: boolean +} + +// --- Query Keys --- + +export const posKeys = { + transaction: (id: string) => ['pos', 'transaction', id] as const, + drawer: (locationId: string) => ['pos', 'drawer', locationId] as const, + products: (search: string) => ['pos', 'products', search] as const, + discounts: ['pos', 'discounts'] as const, +} + +// --- Query Options --- + +export function transactionOptions(id: string | null) { + return queryOptions({ + queryKey: posKeys.transaction(id ?? ''), + queryFn: () => api.get(`/v1/transactions/${id}`), + enabled: !!id, + }) +} + +export function currentDrawerOptions(locationId: string | null) { + return queryOptions({ + queryKey: posKeys.drawer(locationId ?? ''), + queryFn: () => api.get('/v1/drawer/current', { locationId }), + enabled: !!locationId, + retry: false, + }) +} + +export function productSearchOptions(search: string) { + return queryOptions({ + queryKey: posKeys.products(search), + queryFn: () => api.get<{ data: Product[]; pagination: any }>('/v1/products', { q: search, limit: 24, isActive: true }), + enabled: search.length >= 1, + }) +} + +export function discountListOptions() { + return queryOptions({ + queryKey: posKeys.discounts, + queryFn: () => api.get('/v1/discounts/all'), + }) +} + +// --- Mutations --- + +export const posMutations = { + createTransaction: (data: { transactionType: string; locationId?: string }) => + api.post('/v1/transactions', data), + + addLineItem: (txnId: string, data: { productId?: string; inventoryUnitId?: string; description: string; qty: number; unitPrice: number }) => + api.post(`/v1/transactions/${txnId}/line-items`, data), + + removeLineItem: (txnId: string, lineItemId: string) => + api.del(`/v1/transactions/${txnId}/line-items/${lineItemId}`), + + applyDiscount: (txnId: string, data: { discountId?: string; amount: number; reason: string; lineItemId?: string }) => + api.post(`/v1/transactions/${txnId}/discounts`, data), + + complete: (txnId: string, data: { paymentMethod: string; amountTendered?: number; checkNumber?: string }) => + api.post(`/v1/transactions/${txnId}/complete`, data), + + void: (txnId: string) => + api.post(`/v1/transactions/${txnId}/void`, {}), + + openDrawer: (data: { locationId?: string; openingBalance: number }) => + api.post('/v1/drawer/open', data), + + closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record; notes?: string }) => + api.post(`/v1/drawer/${id}/close`, data), + + lookupUpc: (upc: string) => + api.get(`/v1/products/lookup/upc/${upc}`), +} diff --git a/packages/admin/src/components/pos/pos-cart-panel.tsx b/packages/admin/src/components/pos/pos-cart-panel.tsx new file mode 100644 index 0000000..3f530da --- /dev/null +++ b/packages/admin/src/components/pos/pos-cart-panel.tsx @@ -0,0 +1,179 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { usePOSStore } from '@/stores/pos.store' +import { posMutations, posKeys, type Transaction } from '@/api/pos' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { X, Banknote, CreditCard, FileText, Ban } from 'lucide-react' +import { toast } from 'sonner' +import { useState } from 'react' +import { POSPaymentDialog } from './pos-payment-dialog' + +interface POSCartPanelProps { + transaction: Transaction | null +} + +export function POSCartPanel({ transaction }: POSCartPanelProps) { + const queryClient = useQueryClient() + const { currentTransactionId, setTransaction } = usePOSStore() + const [paymentMethod, setPaymentMethod] = useState(null) + const lineItems = transaction?.lineItems ?? [] + + const removeItemMutation = useMutation({ + mutationFn: (lineItemId: string) => + posMutations.removeLineItem(currentTransactionId!, lineItemId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) }) + }, + onError: (err) => toast.error(err.message), + }) + + const voidMutation = useMutation({ + mutationFn: () => posMutations.void(currentTransactionId!), + onSuccess: () => { + setTransaction(null) + toast.success('Transaction voided') + }, + onError: (err) => toast.error(err.message), + }) + + const subtotal = parseFloat(transaction?.subtotal ?? '0') + const discountTotal = parseFloat(transaction?.discountTotal ?? '0') + const taxTotal = parseFloat(transaction?.taxTotal ?? '0') + const total = parseFloat(transaction?.total ?? '0') + const hasItems = lineItems.length > 0 + const isPending = transaction?.status === 'pending' + + function handlePaymentComplete() { + setPaymentMethod(null) + setTransaction(null) + } + + return ( +
+ {/* Header */} +
+
+

Current Sale

+ {transaction && ( + + {transaction.transactionNumber} + + )} +
+
+ + {/* Line items */} +
+ {lineItems.length === 0 ? ( +
+ No items yet +
+ ) : ( +
+ {lineItems.map((item) => ( +
+
+

{item.description}

+

+ {item.qty} x ${parseFloat(item.unitPrice).toFixed(2)} + {parseFloat(item.taxAmount) > 0 && ( + tax ${parseFloat(item.taxAmount).toFixed(2)} + )} +

+
+ + ${parseFloat(item.lineTotal).toFixed(2)} + + {isPending && ( + + )} +
+ ))} +
+ )} +
+ + {/* Totals + payment */} +
+
+
+ Subtotal + ${subtotal.toFixed(2)} +
+ {discountTotal > 0 && ( +
+ Discount + -${discountTotal.toFixed(2)} +
+ )} +
+ Tax + ${taxTotal.toFixed(2)} +
+ +
+ Total + ${total.toFixed(2)} +
+
+ + {/* Payment buttons */} +
+ + + + +
+
+ + {/* Payment dialog */} + {paymentMethod && transaction && ( + { if (!open) setPaymentMethod(null) }} + paymentMethod={paymentMethod} + transaction={transaction} + onComplete={handlePaymentComplete} + /> + )} +
+ ) +} diff --git a/packages/admin/src/components/pos/pos-drawer-dialog.tsx b/packages/admin/src/components/pos/pos-drawer-dialog.tsx new file mode 100644 index 0000000..7ad2425 --- /dev/null +++ b/packages/admin/src/components/pos/pos-drawer-dialog.tsx @@ -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 ( + + + + {isOpen ? 'Close Drawer' : 'Open Drawer'} + + + {isOpen ? ( +
+
+
+ Opening Balance + ${parseFloat(drawer!.openingBalance).toFixed(2)} +
+
+ Opened + {new Date(drawer!.openedAt).toLocaleTimeString()} +
+
+ +
+ + setClosingBalance(e.target.value)} + placeholder="Count the cash in the drawer" + className="h-11 text-lg" + autoFocus + /> +
+
+ + setNotes(e.target.value)} + placeholder="End of shift notes" + className="h-11" + /> +
+ +
+ ) : ( +
+
+ + setOpeningBalance(e.target.value)} + placeholder="Starting cash amount" + className="h-11 text-lg" + autoFocus + /> +
+ +
+ )} +
+
+ ) +} diff --git a/packages/admin/src/components/pos/pos-item-panel.tsx b/packages/admin/src/components/pos/pos-item-panel.tsx new file mode 100644 index 0000000..bf4bb7f --- /dev/null +++ b/packages/admin/src/components/pos/pos-item-panel.tsx @@ -0,0 +1,264 @@ +import { useState, useRef, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { usePOSStore } from '@/stores/pos.store' +import { productSearchOptions, posMutations, posKeys, type Transaction, type Product } from '@/api/pos' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { Search, ScanBarcode, Wrench, PenLine } from 'lucide-react' +import { toast } from 'sonner' + +interface POSItemPanelProps { + transaction: Transaction | null +} + +export function POSItemPanel({ transaction }: POSItemPanelProps) { + const queryClient = useQueryClient() + const { currentTransactionId, setTransaction, locationId } = usePOSStore() + const [search, setSearch] = useState('') + const [customOpen, setCustomOpen] = useState(false) + const [customDesc, setCustomDesc] = useState('') + const [customPrice, setCustomPrice] = useState('') + const [customQty, setCustomQty] = useState('1') + const searchRef = useRef(null) + + // Debounced product search + const { data: productsData, isLoading: searchLoading } = useQuery({ + ...productSearchOptions(search), + enabled: search.length >= 1, + }) + const products = productsData?.data ?? [] + + // Add line item mutation + const addItemMutation = useMutation({ + mutationFn: async (product: Product) => { + let txnId = currentTransactionId + // Auto-create transaction if none exists + if (!txnId) { + const txn = await posMutations.createTransaction({ + transactionType: 'sale', + locationId: locationId ?? undefined, + }) + txnId = txn.id + setTransaction(txnId) + } + return posMutations.addLineItem(txnId, { + productId: product.id, + description: product.name, + qty: 1, + unitPrice: parseFloat(product.sellingPrice ?? '0'), + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') }) + }, + onError: (err) => toast.error(err.message), + }) + + // Custom item mutation + const addCustomMutation = useMutation({ + mutationFn: async () => { + let txnId = currentTransactionId + if (!txnId) { + const txn = await posMutations.createTransaction({ + transactionType: 'sale', + locationId: locationId ?? undefined, + }) + txnId = txn.id + setTransaction(txnId) + } + return posMutations.addLineItem(txnId, { + description: customDesc, + qty: parseInt(customQty) || 1, + unitPrice: parseFloat(customPrice) || 0, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') }) + setCustomOpen(false) + setCustomDesc('') + setCustomPrice('') + setCustomQty('1') + }, + onError: (err) => toast.error(err.message), + }) + + // UPC scan + const scanMutation = useMutation({ + mutationFn: async (upc: string) => { + const product = await posMutations.lookupUpc(upc) + let txnId = currentTransactionId + if (!txnId) { + const txn = await posMutations.createTransaction({ + transactionType: 'sale', + locationId: locationId ?? undefined, + }) + txnId = txn.id + setTransaction(txnId) + } + return posMutations.addLineItem(txnId, { + productId: product.id, + description: product.name, + qty: 1, + unitPrice: parseFloat(product.sellingPrice ?? '0'), + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') }) + setSearch('') + toast.success('Item scanned') + }, + onError: (err) => toast.error(err.message), + }) + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + // 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 ( +
+ {/* Search bar */} +
+
+ + setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Search products or scan barcode..." + className="pl-10 h-11 text-base" + autoFocus + /> +
+
+ + {/* Product grid */} +
+ {searchLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : products.length > 0 ? ( +
+ {products.map((product) => ( + + ))} +
+ ) : search.length >= 1 ? ( +
+ No products found for "{search}" +
+ ) : ( +
+ Search for products to add to the sale +
+ )} +
+ + {/* Quick action buttons */} +
+ + + +
+ + {/* Custom item dialog */} + + + + Add Custom Item + +
{ e.preventDefault(); addCustomMutation.mutate() }} + className="space-y-4" + > +
+ + setCustomDesc(e.target.value)} + placeholder="Item description" + required + className="h-11" + /> +
+
+
+ + setCustomPrice(e.target.value)} + placeholder="0.00" + required + className="h-11" + /> +
+
+ + setCustomQty(e.target.value)} + className="h-11" + /> +
+
+ +
+
+
+
+ ) +} diff --git a/packages/admin/src/components/pos/pos-payment-dialog.tsx b/packages/admin/src/components/pos/pos-payment-dialog.tsx new file mode 100644 index 0000000..06aa9f3 --- /dev/null +++ b/packages/admin/src/components/pos/pos-payment-dialog.tsx @@ -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(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 ( + handleDone()}> + +
+ +

Sale Complete

+

{result.transactionNumber}

+ +
+
+ Total + ${parseFloat(result.total).toFixed(2)} +
+ {roundingAdj !== 0 && ( +
+ Rounding + {roundingAdj > 0 ? '+' : ''}{roundingAdj.toFixed(2)} +
+ )} + {paymentMethod === 'cash' && ( + <> +
+ Tendered + ${parseFloat(result.amountTendered ?? '0').toFixed(2)} +
+ {changeGiven > 0 && ( +
+ Change Due + ${changeGiven.toFixed(2)} +
+ )} + + )} +
+ + +
+
+
+ ) + } + + return ( + + + + + {paymentMethod === 'cash' ? 'Cash Payment' : paymentMethod === 'check' ? 'Check Payment' : 'Card Payment'} + + + +
+
+ Total Due + ${total.toFixed(2)} +
+ + + + {paymentMethod === 'cash' && ( + <> +
+ + setAmountTendered(e.target.value)} + placeholder="0.00" + className="h-12 text-xl text-right font-mono" + autoFocus + /> +
+
+ {QUICK_AMOUNTS.map((amt) => ( + + ))} +
+ + {tenderedAmount >= total && ( +
+ Change + ${changeDue.toFixed(2)} +
+ )} + + )} + + {paymentMethod === 'check' && ( +
+ + setCheckNumber(e.target.value)} + placeholder="Check #" + className="h-11" + autoFocus + /> +
+ )} + + {paymentMethod === 'card_present' && ( +

+ Process card payment on terminal, then confirm below. +

+ )} + + +
+
+
+ ) +} diff --git a/packages/admin/src/components/pos/pos-register.tsx b/packages/admin/src/components/pos/pos-register.tsx new file mode 100644 index 0000000..92cc852 --- /dev/null +++ b/packages/admin/src/components/pos/pos-register.tsx @@ -0,0 +1,72 @@ +import { useEffect } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { usePOSStore } from '@/stores/pos.store' +import { currentDrawerOptions, transactionOptions } from '@/api/pos' +import { POSTopBar } from './pos-top-bar' +import { POSItemPanel } from './pos-item-panel' +import { POSCartPanel } from './pos-cart-panel' + +interface Location { + id: string + name: string +} + +function locationsOptions() { + return queryOptions({ + queryKey: ['locations'], + queryFn: () => api.get<{ data: Location[] }>('/v1/locations'), + }) +} + +export function POSRegister() { + const { locationId, setLocation, currentTransactionId, drawerSessionId, setDrawerSession } = usePOSStore() + const queryClient = useQueryClient() + + // Fetch locations + const { data: locationsData } = useQuery(locationsOptions()) + const locations = locationsData?.data ?? [] + + // Auto-select first location + useEffect(() => { + if (!locationId && locations.length > 0) { + setLocation(locations[0].id) + } + }, [locationId, locations, setLocation]) + + // Fetch current drawer for selected location + const { data: drawer } = useQuery({ + ...currentDrawerOptions(locationId), + retry: false, + }) + + // Sync drawer session ID + useEffect(() => { + if (drawer?.id && drawer.status === 'open') { + setDrawerSession(drawer.id) + } + }, [drawer, setDrawerSession]) + + // Fetch current transaction + const { data: transaction } = useQuery(transactionOptions(currentTransactionId)) + + return ( +
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/packages/admin/src/components/pos/pos-top-bar.tsx b/packages/admin/src/components/pos/pos-top-bar.tsx new file mode 100644 index 0000000..250308b --- /dev/null +++ b/packages/admin/src/components/pos/pos-top-bar.tsx @@ -0,0 +1,91 @@ +import { Link, useRouter } from '@tanstack/react-router' +import { useAuthStore } from '@/stores/auth.store' +import { usePOSStore } from '@/stores/pos.store' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { ArrowLeft, LogOut, DollarSign } from 'lucide-react' +import type { DrawerSession } from '@/api/pos' +import { useState } from 'react' +import { POSDrawerDialog } from './pos-drawer-dialog' + +interface POSTopBarProps { + locations: { id: string; name: string }[] + locationId: string | null + onLocationChange: (id: string) => void + drawer: DrawerSession | null +} + +export function POSTopBar({ locations, locationId, onLocationChange, drawer }: POSTopBarProps) { + const router = useRouter() + const user = useAuthStore((s) => s.user) + const logout = useAuthStore((s) => s.logout) + const [drawerDialogOpen, setDrawerDialogOpen] = useState(false) + + const drawerOpen = drawer?.status === 'open' + + function handleLogout() { + logout() + router.navigate({ to: '/login', replace: true }) + } + + return ( + <> +
+ {/* Left: back + location */} +
+ + + Admin + + + {locations.length > 1 ? ( + + ) : locations.length === 1 ? ( + {locations[0].name} + ) : null} +
+ + {/* Center: drawer status */} + + + {/* Right: user + logout */} +
+ {user?.firstName} + +
+
+ + + + ) +} diff --git a/packages/admin/src/routeTree.gen.ts b/packages/admin/src/routeTree.gen.ts index 56f3fee..7be384e 100644 --- a/packages/admin/src/routeTree.gen.ts +++ b/packages/admin/src/routeTree.gen.ts @@ -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. import { Route as rootRouteImport } from './routes/__root' +import { Route as PosRouteImport } from './routes/pos' import { Route as LoginRouteImport } from './routes/login' import { Route as AuthenticatedRouteImport } from './routes/_authenticated' 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 AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId' +const PosRoute = PosRouteImport.update({ + id: '/pos', + path: '/pos', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -323,6 +329,7 @@ const AuthenticatedLessonsScheduleInstructorsInstructorIdRoute = export interface FileRoutesByFullPath { '/': typeof AuthenticatedIndexRoute '/login': typeof LoginRoute + '/pos': typeof PosRoute '/help': typeof AuthenticatedHelpRoute '/profile': typeof AuthenticatedProfileRoute '/settings': typeof AuthenticatedSettingsRoute @@ -369,6 +376,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/login': typeof LoginRoute + '/pos': typeof PosRoute '/help': typeof AuthenticatedHelpRoute '/profile': typeof AuthenticatedProfileRoute '/settings': typeof AuthenticatedSettingsRoute @@ -417,6 +425,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_authenticated': typeof AuthenticatedRouteWithChildren '/login': typeof LoginRoute + '/pos': typeof PosRoute '/_authenticated/help': typeof AuthenticatedHelpRoute '/_authenticated/profile': typeof AuthenticatedProfileRoute '/_authenticated/settings': typeof AuthenticatedSettingsRoute @@ -467,6 +476,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/pos' | '/help' | '/profile' | '/settings' @@ -513,6 +523,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/login' + | '/pos' | '/help' | '/profile' | '/settings' @@ -560,6 +571,7 @@ export interface FileRouteTypes { | '__root__' | '/_authenticated' | '/login' + | '/pos' | '/_authenticated/help' | '/_authenticated/profile' | '/_authenticated/settings' @@ -609,10 +621,18 @@ export interface FileRouteTypes { export interface RootRouteChildren { AuthenticatedRoute: typeof AuthenticatedRouteWithChildren LoginRoute: typeof LoginRoute + PosRoute: typeof PosRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/pos': { + id: '/pos' + path: '/pos' + fullPath: '/pos' + preLoaderRoute: typeof PosRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -1069,6 +1089,7 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { AuthenticatedRoute: AuthenticatedRouteWithChildren, LoginRoute: LoginRoute, + PosRoute: PosRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/admin/src/routes/_authenticated.tsx b/packages/admin/src/routes/_authenticated.tsx index 16adec0..8f342cc 100644 --- a/packages/admin/src/routes/_authenticated.tsx +++ b/packages/admin/src/routes/_authenticated.tsx @@ -8,7 +8,7 @@ import { myPermissionsOptions } from '@/api/rbac' import { moduleListOptions } from '@/api/modules' import { Avatar } from '@/components/shared/avatar-upload' 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')({ beforeLoad: () => { @@ -145,6 +145,7 @@ function AuthenticatedLayout() { const canViewLessons = !permissionsLoaded || hasPermission('lessons.view') const canViewInventory = !permissionsLoaded || hasPermission('inventory.view') const canViewUsers = !permissionsLoaded || hasPermission('users.view') + const canViewPOS = !permissionsLoaded || hasPermission('pos.view') const [collapsed, setCollapsed] = useState(false) @@ -173,6 +174,11 @@ function AuthenticatedLayout() { {/* Scrollable nav links */}
+ {isModuleEnabled('pos') && canViewPOS && ( +
+ } label="Point of Sale" collapsed={collapsed} /> +
+ )} {canViewAccounts && ( } label="Accounts" collapsed={collapsed} /> diff --git a/packages/admin/src/routes/pos.tsx b/packages/admin/src/routes/pos.tsx new file mode 100644 index 0000000..600eddd --- /dev/null +++ b/packages/admin/src/routes/pos.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/packages/admin/src/stores/pos.store.ts b/packages/admin/src/stores/pos.store.ts new file mode 100644 index 0000000..8e8d565 --- /dev/null +++ b/packages/admin/src/stores/pos.store.ts @@ -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((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 }), +}))