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>
107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
import { z } from 'zod';
|
|
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
|
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
|
|
|
|
/**
|
|
* Inner-shape ZodObject — kept exported (without .refine wrapping) so
|
|
* `updateExpenseSchema` can still call `.partial()`. The `.refine()` rule
|
|
* for "receipt or acknowledgement" is applied via `createExpenseSchema`.
|
|
*/
|
|
export const createExpenseShape = z.object({
|
|
establishmentName: z.string().max(200).optional(),
|
|
amount: z.coerce.number().positive(),
|
|
currency: z.string().length(3).default('USD'),
|
|
paymentMethod: z.enum(PAYMENT_METHODS).optional(),
|
|
category: z.enum(EXPENSE_CATEGORIES).optional(),
|
|
payer: z.string().max(200).optional(),
|
|
expenseDate: z.coerce.date(),
|
|
description: z.string().max(2000).optional(),
|
|
receiptFileIds: z.array(z.string()).optional(),
|
|
/**
|
|
* Set to `true` when the rep deliberately creates an expense without a
|
|
* receipt. The UI shows a non-blocking warning that surfaces both at
|
|
* creation time and again in the PDF export. Without this flag, the
|
|
* server rejects an expense submitted with no `receiptFileIds` so reps
|
|
* can't accidentally lose-receipt by mistake.
|
|
*/
|
|
noReceiptAcknowledged: z.boolean().optional().default(false),
|
|
paymentStatus: z.enum(['unpaid', 'paid', 'partial']).default('unpaid'),
|
|
paymentDate: z.string().optional(),
|
|
paymentReference: z.string().optional(),
|
|
paymentNotes: z.string().optional(),
|
|
/**
|
|
* Free-text trip / event label (e.g. "Palm Beach 2026"). Empty / null
|
|
* means "no trip". The form's autocomplete suggests prior values per
|
|
* port to keep spellings consistent so group-by works downstream.
|
|
*/
|
|
tripLabel: z.string().max(120).optional(),
|
|
});
|
|
|
|
export const createExpenseSchema = createExpenseShape.refine(
|
|
(v) => (v.receiptFileIds && v.receiptFileIds.length > 0) || v.noReceiptAcknowledged === true,
|
|
{
|
|
message:
|
|
'Receipt required. Tick "I have no receipt for this expense" if you understand it may not be reimbursed.',
|
|
path: ['receiptFileIds'],
|
|
},
|
|
);
|
|
|
|
// Update accepts partial fields and skips the create-time receipt-or-ack
|
|
// rule (the row already exists and may legitimately be edited without
|
|
// touching receipts).
|
|
export const updateExpenseSchema = createExpenseShape.partial();
|
|
|
|
export const listExpensesSchema = baseListQuerySchema.extend({
|
|
category: z.string().optional(),
|
|
paymentStatus: z.string().optional(),
|
|
dateFrom: z.string().optional(),
|
|
dateTo: z.string().optional(),
|
|
currency: z.string().optional(),
|
|
payer: z.string().optional(),
|
|
tripLabel: z.string().optional(),
|
|
});
|
|
|
|
/**
|
|
* Body for `POST /api/v1/expenses/export/pdf`. Mirrors the legacy
|
|
* `PDFOptions` shape from the Nuxt client-portal so reps can re-use the
|
|
* same mental model. `expenseIds` selects an explicit subset; when
|
|
* absent, the listExpenses-style filter is used to gather rows.
|
|
*
|
|
* Limits are deliberate:
|
|
* - max 1000 expenseIds so a runaway selection can't queue an OOM-able
|
|
* receipt-fetch loop (see expense-pdf.service.ts).
|
|
* - documentName is sanitized at the service layer for the filename;
|
|
* the validator only enforces a sane upper bound.
|
|
*/
|
|
export const exportExpensePdfSchema = z.object({
|
|
expenseIds: z.array(z.string()).max(1000).optional(),
|
|
filter: z
|
|
.object({
|
|
dateFrom: z.string().optional().nullable(),
|
|
dateTo: z.string().optional().nullable(),
|
|
category: z.string().optional().nullable(),
|
|
paymentStatus: z.string().optional().nullable(),
|
|
payer: z.string().optional().nullable(),
|
|
tripLabel: z.string().optional().nullable(),
|
|
includeArchived: z.boolean().optional(),
|
|
})
|
|
.optional(),
|
|
options: z.object({
|
|
documentName: z.string().min(1).max(200),
|
|
subheader: z.string().max(300).optional(),
|
|
groupBy: z.enum(['none', 'payer', 'category', 'date', 'trip']).default('none'),
|
|
includeReceipts: z.boolean().default(false),
|
|
includeReceiptContents: z.boolean().default(false),
|
|
includeSummary: z.boolean().default(true),
|
|
includeDetails: z.boolean().default(true),
|
|
includeProcessingFee: z.boolean().default(false),
|
|
targetCurrency: z.enum(['USD', 'EUR']).default('EUR'),
|
|
pageFormat: z.enum(['A4', 'Letter', 'Legal']).default('A4'),
|
|
}),
|
|
});
|
|
|
|
export type CreateExpenseInput = z.infer<typeof createExpenseSchema>;
|
|
export type UpdateExpenseInput = z.infer<typeof updateExpenseSchema>;
|
|
export type ListExpensesInput = z.infer<typeof listExpensesSchema>;
|
|
export type ExportExpensePdfInput = z.infer<typeof exportExpensePdfSchema>;
|