diff --git a/src/app/api/v1/expenses/export/pdf/route.ts b/src/app/api/v1/expenses/export/pdf/route.ts index 7b7bb04..7b7548f 100644 --- a/src/app/api/v1/expenses/export/pdf/route.ts +++ b/src/app/api/v1/expenses/export/pdf/route.ts @@ -45,6 +45,7 @@ export const POST = withAuth( category: input.filter.category ?? null, paymentStatus: input.filter.paymentStatus ?? null, payer: input.filter.payer ?? null, + tripLabel: input.filter.tripLabel ?? null, includeArchived: input.filter.includeArchived ?? false, } : undefined, diff --git a/src/app/api/v1/expenses/trip-labels/route.ts b/src/app/api/v1/expenses/trip-labels/route.ts new file mode 100644 index 0000000..a864155 --- /dev/null +++ b/src/app/api/v1/expenses/trip-labels/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { listTripLabels } from '@/lib/services/expenses'; + +/** + * GET /api/v1/expenses/trip-labels?search=palm + * + * Returns the distinct trip-label values used in this port, ordered by + * most-recent expense date. Powers the autocomplete on the expense form + * + the trip filter on the list page so reps don't end up with + * "Palm Beach 2026" vs " palm beach 2026 " split across two groups in + * the PDF export. + * + * Permission: `expenses.view` — same gate as the list endpoint. + */ +export const GET = withAuth( + withPermission('expenses', 'view', async (req, ctx) => { + try { + const url = new URL(req.url); + const search = url.searchParams.get('search') ?? undefined; + const labels = await listTripLabels(ctx.portId, search); + return NextResponse.json({ data: labels }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/expenses/expense-columns.tsx b/src/components/expenses/expense-columns.tsx index c5beb23..089d39f 100644 --- a/src/components/expenses/expense-columns.tsx +++ b/src/components/expenses/expense-columns.tsx @@ -28,6 +28,7 @@ export interface ExpenseRow { payer: string | null; receiptFileIds: string[] | null; noReceiptAcknowledged?: boolean; + tripLabel: string | null; archivedAt: string | null; createdAt: string; /** Set by the dedup engine when this expense looks like a duplicate of another. */ @@ -122,6 +123,21 @@ export function getExpenseColumns({ ); }, }, + { + id: 'tripLabel', + accessorKey: 'tripLabel', + header: 'Trip', + enableSorting: false, + cell: ({ getValue }) => { + const trip = getValue() as string | null; + if (!trip) return -; + return ( + + {trip} + + ); + }, + }, { id: 'paymentStatus', accessorKey: 'paymentStatus', diff --git a/src/components/expenses/expense-detail.tsx b/src/components/expenses/expense-detail.tsx index 649e826..0b9cda1 100644 --- a/src/components/expenses/expense-detail.tsx +++ b/src/components/expenses/expense-detail.tsx @@ -235,6 +235,18 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr Payer
{expense.payer ?? '-'}
+ {expense.tripLabel ? ( + + {expense.tripLabel} + + ) : ( + '-' + )} +
{expense.description ?? '-'}
+ Group expenses by yacht show or business trip. Leave empty for everyday spend. +