Backend: - Server-side HTML email templates (receipt + estimate) with inline CSS - POST /v1/transactions/:id/email-receipt with per-transaction rate limiting - POST /v1/repair-tickets/:id/email-estimate with per-ticket rate limiting - customerEmail field added to receipt and ticket detail responses - Test email provider for API tests (logs instead of sending) Frontend: - POS payment dialog Email button enabled with inline email input - Pre-fills customer email from linked account - Repair ticket detail page has Email Estimate button with dialog - Pre-fills from account email Tests: - 12 unit tests for email template renderers - 8 API tests for email receipt/estimate endpoints and validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 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 }[] }
|
|
customerEmail: string | null
|
|
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)
|
|
const [emailMode, setEmailMode] = useState(false)
|
|
const [emailAddress, setEmailAddress] = useState('')
|
|
const [emailSent, setEmailSent] = useState(false)
|
|
|
|
const emailReceiptMutation = useMutation({
|
|
mutationFn: () => api.post<{ message: string }>(`/v1/transactions/${result!.id}/email-receipt`, { email: emailAddress }),
|
|
onSuccess: () => {
|
|
toast.success('Receipt emailed')
|
|
setEmailMode(false)
|
|
setEmailSent(true)
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
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>
|
|
|
|
{emailMode ? (
|
|
<div className="w-full space-y-2">
|
|
<Label className="text-xs text-muted-foreground">Email receipt to:</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="email"
|
|
value={emailAddress}
|
|
onChange={(e) => setEmailAddress(e.target.value)}
|
|
placeholder="customer@example.com"
|
|
className="h-9"
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
className="h-9 px-4"
|
|
onClick={() => emailReceiptMutation.mutate()}
|
|
disabled={!emailAddress || emailReceiptMutation.isPending}
|
|
>
|
|
{emailReceiptMutation.isPending ? 'Sending...' : 'Send'}
|
|
</Button>
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setEmailMode(false)}>Cancel</Button>
|
|
</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"
|
|
onClick={() => {
|
|
setEmailAddress(receiptData?.customerEmail ?? '')
|
|
setEmailMode(true)
|
|
}}
|
|
disabled={emailSent}
|
|
>
|
|
<Mail className="h-4 w-4" />{emailSent ? 'Sent' : '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>
|
|
)
|
|
}
|