Per the trips/events design discussion: instead of building a full
events domain (table + CRUD UI + calendar) for the 6–12 yacht shows
a year, ship the cheap version that covers the actual asks.
Expenses — `tripLabel` free-text:
- New `expenses.trip_label` text column (migration 0039) + index for
filter / autocomplete lookup.
- Validator: createExpenseShape + listExpensesSchema +
exportExpensePdfSchema.filter all accept tripLabel.
- Service: createExpense + updateExpense persist; listExpenses filters;
new `listTripLabels(portId, search?)` returns distinct values
ordered by most-recent expenseDate so the autocomplete surfaces
recently-used labels first.
- New `GET /api/v1/expenses/trip-labels` endpoint (gated by
expenses.view) backs the autocomplete.
- Form dialog: native `<datalist>` powered by the autocomplete query
so reps don't end up with "Palm Beach 2026" / "palm-beach 2026"
fragmented across two PDF sections.
- Expense list: new "Trip" column (badge) + free-text filter.
- Detail page: trip label rendered alongside Category / Payer.
- PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the
export. Untagged rows fall under "(no trip)".
- Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026".
Interests — event tagging via existing tag system:
- Reps can tag interests with an event tag (e.g. "Palm Beach 2026")
via the existing InlineTagEditor on the detail page; tags are
port-scoped and reusable.
- Interest list now has a TagPicker filter rendered next to the
FilterBar so reps can sort prospects by event attended ("show me
every lead from Palm Beach"). Hidden 'relation'-typed
FilterDefinition for tagIds wires URL round-trip + saved-views
capture without rendering inside the FilterBar.
- FilterBar deserializer now handles `relation` types as comma-joined
arrays on URL load.
Why a free-text trip label and not a trips table:
- 6–12 events/year doesn't justify a domain. The CRUD UI cost would
be most of the engineering, and reps already have the events on
their personal calendars.
- If usage proves demand for per-event ROI dashboards or richer
attribution, promote to a real `trips` table later. Migration
path: trip_label → tripId is a backfill+swap.
Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied
in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037
had `idx_br_interest` colliding with the existing
`berth_recommendations.idx_br_interest`; renamed to
`idx_brr_interest` / `idx_brr_contract_file`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
287 lines
9.8 KiB
TypeScript
287 lines
9.8 KiB
TypeScript
'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 (
|
|
<div className="flex h-32 items-center justify-center rounded border bg-muted/40 text-xs text-muted-foreground">
|
|
<Loader2 className="mr-2 h-3 w-3 animate-spin" /> Loading preview…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const url = data?.data?.url;
|
|
const mime = data?.data?.mimeType ?? '';
|
|
const isImage = mime.startsWith('image/');
|
|
|
|
return (
|
|
<div className="rounded border bg-muted/40 p-2">
|
|
{url && isImage ? (
|
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
src={url}
|
|
alt="Receipt"
|
|
className="h-32 w-full rounded object-cover hover:opacity-90"
|
|
/>
|
|
</a>
|
|
) : (
|
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
|
<FileText className="h-8 w-8" />
|
|
</div>
|
|
)}
|
|
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
|
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
|
|
<a
|
|
href={`/api/v1/files/${fileId}/download`}
|
|
className="inline-flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
<Download className="h-3 w-3" /> Download
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
|
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 (
|
|
<div className="flex items-center justify-center p-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !data?.data) {
|
|
return (
|
|
<div className="p-6 text-center text-muted-foreground">Failed to load expense details.</div>
|
|
);
|
|
}
|
|
|
|
const expense = data.data;
|
|
const status = expense.paymentStatus ?? 'unpaid';
|
|
const statusColor = PAYMENT_STATUS_COLORS[status] ?? '';
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<ExpenseDuplicateBanner expense={expense} />
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold">
|
|
{expense.establishmentName ?? 'Unnamed Expense'}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{format(new Date(expense.expenseDate), 'MMMM d, yyyy')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{onEdit && (
|
|
<PermissionGate resource="expenses" action="edit">
|
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
|
<Edit className="mr-1.5 h-4 w-4" />
|
|
Edit
|
|
</Button>
|
|
</PermissionGate>
|
|
)}
|
|
<PermissionGate resource="expenses" action="delete">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-destructive"
|
|
onClick={() => setArchiveOpen(true)}
|
|
>
|
|
<Archive className="mr-1.5 h-4 w-4" />
|
|
Archive
|
|
</Button>
|
|
</PermissionGate>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm font-medium">Amount</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold tabular-nums">
|
|
{Number(expense.amount).toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}{' '}
|
|
{expense.currency}
|
|
</p>
|
|
{expense.amountUsd && expense.currency !== 'USD' && (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
≈ $
|
|
{Number(expense.amountUsd).toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}{' '}
|
|
USD
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
|
|
{status}
|
|
</Badge>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm font-medium">Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span className="text-muted-foreground">Category</span>
|
|
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Payment Method</span>
|
|
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Payer</span>
|
|
<p className="mt-0.5">{expense.payer ?? '-'}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Trip / event</span>
|
|
<p className="mt-0.5">
|
|
{expense.tripLabel ? (
|
|
<Badge variant="secondary" className="text-xs font-normal">
|
|
{expense.tripLabel}
|
|
</Badge>
|
|
) : (
|
|
'-'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Description</span>
|
|
<p className="mt-0.5">{expense.description ?? '-'}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{expense.receiptFileIds && expense.receiptFileIds.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<Receipt className="h-4 w-4" />
|
|
Receipts ({expense.receiptFileIds.length})
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{(expense.receiptFileIds as string[]).map((fileId) => (
|
|
<ReceiptThumbnail key={fileId} fileId={fileId} />
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<ArchiveConfirmDialog
|
|
open={archiveOpen}
|
|
onOpenChange={setArchiveOpen}
|
|
entityName={expense.establishmentName ?? 'this expense'}
|
|
entityType="Expense"
|
|
isArchived={false}
|
|
onConfirm={() => archiveMutation.mutate()}
|
|
isLoading={archiveMutation.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|