feat: email receipts and repair estimates
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>
This commit is contained in:
@@ -84,6 +84,7 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
|
||||
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`),
|
||||
@@ -91,6 +92,19 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -140,14 +154,47 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
|
||||
)}
|
||||
</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>
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user