'use client'; import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2, Send, CreditCard } from 'lucide-react'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { format } from 'date-fns'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { PermissionGate } from '@/components/shared/permission-gate'; import { toast } from 'sonner'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { recordPaymentSchema, type RecordPaymentInput } from '@/lib/validators/invoices'; const STATUS_COLORS: Record = { draft: 'bg-gray-100 text-gray-700 border-gray-200', sent: 'bg-blue-100 text-blue-700 border-blue-200', paid: 'bg-green-100 text-green-700 border-green-200', overdue: 'bg-red-100 text-red-700 border-red-200', cancelled: 'bg-gray-100 text-gray-500 border-gray-200', }; // Display labels for snake_case enum values stored in the DB. const PAYMENT_METHOD_LABELS: Record = { bank_transfer: 'Bank transfer', credit_card: 'Credit card', cash: 'Cash', cheque: 'Cheque', check: 'Check', wire: 'Wire', other: 'Other', }; const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [ { value: 'bank_transfer', label: 'Bank transfer' }, { value: 'credit_card', label: 'Credit card' }, { value: 'cash', label: 'Cash' }, { value: 'cheque', label: 'Cheque' }, { value: 'wire', label: 'Wire' }, { value: 'other', label: 'Other' }, ]; function formatPaymentMethod(method: string | null | undefined): string { if (!method) return '-'; return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' '); } function formatDateOnly(value: string | null | undefined): string { if (!value) return '-'; // Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift. const isoDate = value.length === 10 ? value + 'T00:00:00' : value; const d = new Date(isoDate); if (Number.isNaN(d.getTime())) return value; return format(d, 'MMM d, yyyy'); } interface InvoiceDetailProps { invoiceId: string; } interface InvoiceLineItem { id: string; description: string; quantity: number | string; unitPrice: number | string; total: number | string; } interface InvoiceLinkedExpense { id: string; establishmentName: string | null; category: string | null; expenseDate: string; amount: number | string; currency: string; } interface InvoiceDetailData { id: string; invoiceNumber: string; status: string; clientName: string; currency: string; total: number | string; subtotal: number | string; discountAmount: number | string; discountPct: number | string; feeAmount: number | string; feePct: number | string; dueDate: string | null; paymentTerms: string | null; notes: string | null; pdfFileId: string | null; paymentDate: string | null; paymentMethod: string | null; paymentReference: string | null; lineItems?: InvoiceLineItem[]; linkedExpenses?: InvoiceLinkedExpense[]; } export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { const queryClient = useQueryClient(); const [tab, setTab] = useState('overview'); const { data, isLoading, error } = useQuery<{ data: InvoiceDetailData }>({ queryKey: ['invoices', invoiceId], queryFn: () => apiFetch<{ data: InvoiceDetailData }>(`/api/v1/invoices/${invoiceId}`), }); const { setChrome } = useMobileChrome(); const titleForChrome: string | null = data?.data?.invoiceNumber ?? null; useEffect(() => { setChrome({ title: titleForChrome, showBackButton: true }); return () => setChrome({ title: null, showBackButton: false }); }, [titleForChrome, setChrome]); const sendMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }), onSuccess: () => { toast.success('Invoice sent'); queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); }, onError: (e) => toastError(e), }); const paymentForm = useForm({ resolver: zodResolver(recordPaymentSchema), defaultValues: { paymentDate: new Date().toISOString().split('T')[0] }, }); const paymentMutation = useMutation({ mutationFn: (values: RecordPaymentInput) => apiFetch(`/api/v1/invoices/${invoiceId}/payment`, { method: 'PATCH', body: values, }), onSuccess: () => { toast.success('Payment recorded'); queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); }, onError: (e) => toastError(e), }); if (isLoading) { return (
); } if (error || !data?.data) { return (
Failed to load invoice details.
); } const invoice = data.data; const statusColor = STATUS_COLORS[invoice.status] ?? STATUS_COLORS.draft; return (
{/* Header */}
Invoice

{invoice.invoiceNumber}

{invoice.status}

{invoice.clientName}

{invoice.status === 'draft' && ( )}
Overview Linked Expenses Payment {/* Overview */}
Total

{Number(invoice.total).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} {invoice.currency}

Due Date

{formatDateOnly(invoice.dueDate)}

Payment Terms

{invoice.paymentTerms}

{/* Line items */} {invoice.lineItems && invoice.lineItems.length > 0 && ( Line Items
Description Qty Unit Price Total
{invoice.lineItems.map((li) => (
{li.description} {li.quantity} {Number(li.unitPrice).toFixed(2)} {Number(li.total).toFixed(2)}
))}
{/* Totals */}
Subtotal {Number(invoice.subtotal).toFixed(2)} {invoice.currency}
{Number(invoice.discountAmount) > 0 && (
Discount ({invoice.discountPct}%) -{Number(invoice.discountAmount).toFixed(2)} {invoice.currency}
)} {Number(invoice.feeAmount) > 0 && (
Fee ({invoice.feePct}%) +{Number(invoice.feeAmount).toFixed(2)} {invoice.currency}
)}
Total {Number(invoice.total).toFixed(2)} {invoice.currency}
)} {invoice.notes && ( Notes

{invoice.notes}

)}
{/* Linked Expenses */} {invoice.linkedExpenses && invoice.linkedExpenses.length > 0 ? (
{invoice.linkedExpenses.map((exp) => (

{exp.establishmentName ?? 'Unnamed Expense'}

{exp.category ?? '-'} · {exp.expenseDate}

{Number(exp.amount).toFixed(2)} {exp.currency}
))}
) : (

No expenses linked to this invoice.

)}
{/* Payment */} {invoice.status === 'paid' ? (
Paid
Payment Date

{formatDateOnly(invoice.paymentDate)}

Method

{formatPaymentMethod(invoice.paymentMethod)}

Reference

{invoice.paymentReference ?? '-'}

) : ( Record Payment
paymentMutation.mutate(values))} className="space-y-4" >
( field.onChange(v || undefined)} /> )} /> {paymentForm.formState.errors.paymentDate && (

{paymentForm.formState.errors.paymentDate.message}

)}
)}
); }