- Add thermal/full-page receipt format toggle (per-device, localStorage) - Full-page receipt uses clean invoice layout matching repair PDF style - Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced) - Manager override system: configurable PIN prompt for void, refund, discount, cash in/out - Discount threshold setting: require manager approval above X% - Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals - Repair line item dialog: product picker dropdown for parts/consumables from inventory - Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods) - Transaction completion auto-updates repair ticket status to picked_up - POS Repairs dialog with Pickup and New Intake tabs, customer account lookup - Inline price adjustment on cart items: % off, $ off, or set price with live preview - Order-level discount button with same three input modes - Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint - Fix: backend dev script uses --env-file for turbo compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
5.9 KiB
TypeScript
146 lines
5.9 KiB
TypeScript
import { useState } 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Printer } from 'lucide-react'
|
|
import { POSReceipt, printReceipt, downloadReceiptPDF } from './pos-receipt'
|
|
import type { Transaction } from '@/api/pos'
|
|
|
|
interface ReceiptData {
|
|
transaction: Transaction & { lineItems: { description: string; qty: number; unitPrice: string; taxAmount: string; lineTotal: string; discountAmount: string }[] }
|
|
company: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
|
|
location: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
|
|
}
|
|
|
|
interface AppConfigEntry { key: string; value: string | null }
|
|
|
|
function recentTransactionsOptions(search: string) {
|
|
return queryOptions({
|
|
queryKey: ['pos', 'recent-transactions', search],
|
|
queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', {
|
|
limit: 15,
|
|
sort: 'created_at',
|
|
order: 'desc',
|
|
...(search ? { q: search } : {}),
|
|
}),
|
|
})
|
|
}
|
|
|
|
interface POSTransactionsDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
}
|
|
|
|
export function POSTransactionsDialog({ open, onOpenChange }: POSTransactionsDialogProps) {
|
|
const [search, setSearch] = useState('')
|
|
const [receiptTxnId, setReceiptTxnId] = useState<string | null>(null)
|
|
|
|
const { data: txnData } = useQuery({
|
|
...recentTransactionsOptions(search),
|
|
enabled: open,
|
|
})
|
|
const transactions = txnData?.data ?? []
|
|
|
|
// Fetch receipt for selected transaction
|
|
const { data: receiptData } = useQuery({
|
|
queryKey: ['pos', 'receipt', receiptTxnId],
|
|
queryFn: () => api.get<ReceiptData>(`/v1/transactions/${receiptTxnId}/receipt`),
|
|
enabled: !!receiptTxnId,
|
|
})
|
|
|
|
const { data: configData } = useQuery({
|
|
queryKey: ['config'],
|
|
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
|
|
enabled: !!receiptTxnId,
|
|
})
|
|
const receiptFormat = usePOSStore((s) => s.receiptFormat)
|
|
const receiptConfig = {
|
|
header: configData?.data?.find((c) => c.key === 'receipt_header')?.value || undefined,
|
|
footer: configData?.data?.find((c) => c.key === 'receipt_footer')?.value || undefined,
|
|
returnPolicy: configData?.data?.find((c) => c.key === 'receipt_return_policy')?.value || undefined,
|
|
social: configData?.data?.find((c) => c.key === 'receipt_social')?.value || undefined,
|
|
}
|
|
|
|
// Receipt view
|
|
if (receiptTxnId && receiptData) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className={`${receiptFormat === 'full' ? 'max-w-2xl' : 'max-w-sm'} max-h-[90vh] overflow-y-auto`}>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setReceiptTxnId(null)}>Back</Button>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(receiptData.transaction.transactionNumber, receiptFormat)}>
|
|
Save PDF
|
|
</Button>
|
|
<Button size="sm" onClick={printReceipt} className="gap-2">
|
|
<Printer className="h-4 w-4" />Print
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div id="pos-receipt-print">
|
|
<POSReceipt data={receiptData} size={receiptFormat} config={receiptConfig} />
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>Recent Transactions</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search by transaction number..."
|
|
className="h-10"
|
|
autoFocus
|
|
/>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{transactions.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">No transactions found</p>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{transactions.map((txn) => (
|
|
<button
|
|
key={txn.id}
|
|
onClick={() => setReceiptTxnId(txn.id)}
|
|
className="w-full text-left py-2.5 px-1 hover:bg-accent/50 rounded transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-mono">{txn.transactionNumber}</span>
|
|
<span className="text-sm font-semibold">${parseFloat(txn.total).toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<Badge
|
|
variant={txn.status === 'completed' ? 'default' : txn.status === 'voided' ? 'destructive' : 'outline'}
|
|
className="text-[10px]"
|
|
>
|
|
{txn.status}
|
|
</Badge>
|
|
{txn.paymentMethod && (
|
|
<span className="text-xs text-muted-foreground">{txn.paymentMethod.replace('_', ' ')}</span>
|
|
)}
|
|
<span className="text-xs text-muted-foreground ml-auto">
|
|
{new Date(txn.completedAt ?? txn.createdAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|