Files
lunarfront-app/packages/admin/src/components/pos/pos-payment-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

252 lines
9.8 KiB
TypeScript

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { api } from '@/lib/api-client'
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, Printer, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { POSReceipt, downloadReceiptPDF, printReceipt } from './pos-receipt'
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!) })
queryClient.invalidateQueries({ queryKey: ['pos', 'products'] })
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]
// Fetch receipt config
interface AppConfigEntry { key: string; value: string | null }
const { data: configData } = useQuery({
queryKey: ['config'],
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
enabled: !!result?.id,
})
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,
}
// Fetch full receipt data after completion
const { data: receiptData } = useQuery({
queryKey: ['pos', 'receipt', result?.id],
queryFn: () => api.get<{
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 }
}>(`/v1/transactions/${result!.id}/receipt`),
enabled: !!result?.id,
})
const [showReceipt, setShowReceipt] = useState(false)
if (completed && result) {
const changeGiven = parseFloat(result.changeGiven ?? '0')
// Receipt print view
if (showReceipt && receiptData) {
return (
<Dialog open={open} onOpenChange={() => { setShowReceipt(false); handleDone() }}>
<DialogContent className={`${receiptFormat === 'full' ? 'max-w-2xl' : 'max-w-sm'} max-h-[90vh] overflow-y-auto print:max-w-none print:max-h-none print:overflow-visible print:shadow-none print:border-none`}>
<div className="flex justify-between items-center mb-2">
<Button variant="ghost" size="sm" onClick={() => setShowReceipt(false)}>Back</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(result.transactionNumber, receiptFormat)} className="gap-2">
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={() => 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>
{paymentMethod === 'cash' && 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>
<div className="w-full grid grid-cols-2 gap-2">
<Button variant="outline" className="h-11 gap-2" onClick={() => setShowReceipt(true)}>
<Printer className="h-4 w-4" />Receipt
</Button>
<Button variant="outline" className="h-11 gap-2" disabled>
<Mail className="h-4 w-4" />Email
</Button>
</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>
)
}