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 ?? '-'}

+
+ Trip / event +

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

+
Description

{expense.description ?? '-'}

diff --git a/src/components/expenses/expense-filters.tsx b/src/components/expenses/expense-filters.tsx index 3e0425c..86160af 100644 --- a/src/components/expenses/expense-filters.tsx +++ b/src/components/expenses/expense-filters.tsx @@ -45,6 +45,12 @@ export const expenseFilterDefinitions: FilterDefinition[] = [ type: 'text', placeholder: 'e.g. USD, EUR', }, + { + key: 'tripLabel', + label: 'Trip / event', + type: 'text', + placeholder: 'e.g. Palm Beach 2026', + }, { key: 'includeArchived', label: 'Include Archived', diff --git a/src/components/expenses/expense-form-dialog.tsx b/src/components/expenses/expense-form-dialog.tsx index 4327fc3..74b389f 100644 --- a/src/components/expenses/expense-form-dialog.tsx +++ b/src/components/expenses/expense-form-dialog.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Loader2, Upload, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -59,6 +59,16 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi }, }); + // Distinct trip labels for the port — fed into the datalist so the + // form input doubles as an autocomplete. Cached for the dialog's life + // since the list rarely changes mid-session. + const tripLabelsQuery = useQuery<{ data: string[] }>({ + queryKey: ['expenses', 'trip-labels'], + queryFn: () => apiFetch('/api/v1/expenses/trip-labels'), + enabled: open, + staleTime: 60_000, + }); + useEffect(() => { if (open && expense) { reset({ @@ -70,6 +80,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi payer: expense.payer ?? undefined, expenseDate: new Date(expense.expenseDate), paymentStatus: (expense.paymentStatus as CreateExpenseInput['paymentStatus']) ?? 'unpaid', + tripLabel: expense.tripLabel ?? undefined, }); setUploadedReceipt(null); setPreviewUrl(null); @@ -295,6 +306,30 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
+
+ + + {/* Native datalist gives the rep prior trip values as + * autocomplete suggestions — keeps spellings consistent + * ("Palm Beach 2026" vs "palm-beach 2026") so the PDF + * group-by-trip section actually merges them. */} + + {(tripLabelsQuery.data?.data ?? []).map((label) => ( + +

+ Group expenses by yacht show or business trip. Leave empty for everyday spend. +

+
+