Files
lunarfront-app/packages/admin/src/components/pos/pos-transactions-dialog.tsx
ryan 95cf017b4b feat: repair-POS integration, receipt formats, manager overrides, price adjustments
- 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>
2026-04-05 16:05:19 +00:00

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