'use client'; import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format } from 'date-fns'; import { Archive, Download, Edit, FileText, Loader2, Receipt } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { PermissionGate } from '@/components/shared/permission-gate'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api/client'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import type { ExpenseRow } from './expense-columns'; import { ExpenseDuplicateBanner } from './expense-duplicate-banner'; /** * Renders an image thumbnail for previewable receipts (jpeg/png/webp/heic * via the existing /files/[id]/preview presign), falling back to a "Download" * link for PDFs and other non-previewable types. Replaces the prior * impossible-to-use UUID-badge list — reps can finally see the receipt * they uploaded against the expense. */ function ReceiptThumbnail({ fileId }: { fileId: string }) { const { data, isLoading, isError } = useQuery<{ data: { url: string; mimeType: string } | null; error?: string; }>({ queryKey: ['file-preview', fileId], queryFn: async () => { try { const res = await apiFetch<{ data: { url: string; mimeType: string } }>( `/api/v1/files/${fileId}/preview`, ); return res; } catch (e) { // Non-image files raise ValidationError ("This file type cannot be // previewed") — fall through to the Download link. return { data: null, error: e instanceof Error ? e.message : 'preview unavailable' }; } }, staleTime: 5 * 60 * 1000, }); if (isLoading) { return (
Loading preview…
); } const url = data?.data?.url; const mime = data?.data?.mimeType ?? ''; const isImage = mime.startsWith('image/'); return (
{url && isImage ? ( Receipt ) : (
)}
{mime || (isError ? 'Receipt' : 'File')} Download
); } const PAYMENT_STATUS_COLORS: Record = { unpaid: 'bg-red-100 text-red-700 border-red-200', paid: 'bg-green-100 text-green-700 border-green-200', partial: 'bg-yellow-100 text-yellow-700 border-yellow-200', }; interface ExpenseDetailProps { expenseId: string; onEdit?: () => void; onArchived?: () => void; } export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailProps) { const queryClient = useQueryClient(); const [archiveOpen, setArchiveOpen] = useState(false); const { data, isLoading, error } = useQuery<{ data: ExpenseRow }>({ queryKey: ['expenses', expenseId], queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`), }); const { setChrome } = useMobileChrome(); const titleForChrome: string | null = data?.data?.establishmentName ?? data?.data?.description?.slice(0, 40) ?? null; useEffect(() => { setChrome({ title: titleForChrome ?? 'Expense', showBackButton: true }); return () => setChrome({ title: null, showBackButton: false }); }, [titleForChrome, setChrome]); const archiveMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['expenses'] }); setArchiveOpen(false); toast.success('Expense archived'); onArchived?.(); }, onError: (e) => { toast.error(e instanceof Error ? e.message : 'Archive failed'); setArchiveOpen(false); }, }); if (isLoading) { return (
); } if (error || !data?.data) { return (
Failed to load expense details.
); } const expense = data.data; const status = expense.paymentStatus ?? 'unpaid'; const statusColor = PAYMENT_STATUS_COLORS[status] ?? ''; return (

{expense.establishmentName ?? 'Unnamed Expense'}

{format(new Date(expense.expenseDate), 'MMMM d, yyyy')}

{onEdit && ( )}
Amount

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

{expense.amountUsd && expense.currency !== 'USD' && (

≈ $ {Number(expense.amountUsd).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} USD

)}
Payment Status {status}
Details
Category

{expense.category?.replace(/_/g, ' ') ?? '-'}

Payment Method

{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}

Payer

{expense.payer ?? '-'}

Trip / event

{expense.tripLabel ? ( {expense.tripLabel} ) : ( '-' )}

Description

{expense.description ?? '-'}

{expense.receiptFileIds && expense.receiptFileIds.length > 0 && ( Receipts ({expense.receiptFileIds.length})
{(expense.receiptFileIds as string[]).map((fileId) => ( ))}
)} archiveMutation.mutate()} isLoading={archiveMutation.isPending} />
); }