feat: email receipts and repair estimates
All checks were successful
CI / ci (pull_request) Successful in 24s
CI / e2e (pull_request) Successful in 1m2s

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:
ryan
2026-04-05 20:32:52 +00:00
parent 30233b430f
commit 45fd6d34eb
11 changed files with 783 additions and 10 deletions

View File

@@ -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

View File

@@ -21,7 +21,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { ArrowLeft, Plus, Trash2, RotateCcw, Save, Search } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, RotateCcw, Save, Search, Mail } from 'lucide-react'
import { PdfModal } from '@/components/repairs/pdf-modal'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth.store'
@@ -78,6 +78,17 @@ function RepairTicketDetailPage() {
const { data: lineItemsData, isLoading: itemsLoading } = useQuery(repairLineItemListOptions(ticketId, params))
const [editFields, setEditFields] = useState<Record<string, string>>({})
const [emailEstimateOpen, setEmailEstimateOpen] = useState(false)
const [estimateEmail, setEstimateEmail] = useState('')
const emailEstimateMutation = useMutation({
mutationFn: () => api.post<{ message: string }>(`/v1/repair-tickets/${ticketId}/email-estimate`, { email: estimateEmail }),
onSuccess: () => {
toast.success('Estimate emailed')
setEmailEstimateOpen(false)
},
onError: (err) => toast.error(err.message),
})
const statusMutation = useMutation({
mutationFn: (status: string) => repairTicketMutations.updateStatus(ticketId, status),
@@ -188,6 +199,37 @@ function RepairTicketDetailPage() {
<h1 className="text-2xl font-bold">Ticket #{ticket.ticketNumber}</h1>
<p className="text-sm text-muted-foreground">{ticket.customerName} {ticket.itemDescription ?? 'No item description'}</p>
</div>
<Dialog open={emailEstimateOpen} onOpenChange={setEmailEstimateOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" onClick={() => setEstimateEmail((ticket as any).customerEmail ?? '')}>
<Mail className="h-4 w-4" />Email Estimate
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Email Estimate</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Send to</Label>
<Input
type="email"
value={estimateEmail}
onChange={(e) => setEstimateEmail(e.target.value)}
placeholder="customer@example.com"
autoFocus
/>
</div>
<Button
className="w-full"
onClick={() => emailEstimateMutation.mutate()}
disabled={!estimateEmail || emailEstimateMutation.isPending}
>
{emailEstimateMutation.isPending ? 'Sending...' : 'Send Estimate'}
</Button>
</div>
</DialogContent>
</Dialog>
<PdfModal ticket={ticket} lineItems={lineItemsData?.data ?? []} ticketId={ticketId} />
</div>