From c4a41d5f5b8e3ec4d9f2c68ed18db55a4fa7a40c Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 5 May 2026 13:46:54 +0200 Subject: [PATCH] feat(expenses+interests): trip/event grouping (lightweight) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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) --- src/app/api/v1/expenses/export/pdf/route.ts | 1 + src/app/api/v1/expenses/trip-labels/route.ts | 29 +++++++++++ src/components/expenses/expense-columns.tsx | 16 ++++++ src/components/expenses/expense-detail.tsx | 12 +++++ src/components/expenses/expense-filters.tsx | 6 +++ .../expenses/expense-form-dialog.tsx | 37 +++++++++++++- src/components/interests/interest-filters.tsx | 8 +++ src/components/interests/interest-list.tsx | 14 +++++- src/components/shared/filter-bar.tsx | 29 ++++------- .../db/migrations/0037_missing_fk_indexes.sql | 6 +-- .../db/migrations/0039_expense_trip_label.sql | 12 +++++ src/lib/db/migrations/meta/_journal.json | 7 +++ src/lib/db/schema/financial.ts | 10 ++++ src/lib/db/schema/reservations.ts | 7 +-- src/lib/services/expense-pdf.service.ts | 11 ++++- src/lib/services/expenses.ts | 49 ++++++++++++++++++- src/lib/validators/expenses.ts | 10 +++- 17 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 src/app/api/v1/expenses/trip-labels/route.ts create mode 100644 src/lib/db/migrations/0039_expense_trip_label.sql 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. +

+
+