chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -150,8 +150,8 @@ async function resolveLeadCategory(
/**
* Soft cap on board rows. The kanban legitimately needs every active
* interest in one shot paginating would split deals across pages and
* break drag-drop semantics but unbounded SELECTs are a footgun if a
* interest in one shot - paginating would split deals across pages and
* break drag-drop semantics - but unbounded SELECTs are a footgun if a
* port suddenly has tens of thousands of stale interests. At 5000 the
* payload is still well under a megabyte (≈50 bytes per minimal row),
* and any port near that ceiling needs virtualization in the kanban UI
@@ -181,12 +181,12 @@ export interface BoardFilters {
/**
* Minimal-projection list for the kanban board. Skips the validator's
* `max(100)` page cap since the board renders the entire pipeline at
* once. Returns only the fields PipelineCard renders no tags-list, no
* once. Returns only the fields PipelineCard renders - no tags-list, no
* notes-count, no EOI status badges, no urgency joins. Always filters
* out archived interests (the kanban is for active deals; the list view
* has the includeArchived toggle for history).
*
* Filters are intentionally a SUBSET of listInterests `pipelineStage`
* Filters are intentionally a SUBSET of listInterests - `pipelineStage`
* is omitted because the columns ARE the stages, and `includeArchived`
* is omitted because the kanban shouldn't surface archived deals.
*
@@ -198,7 +198,7 @@ export async function listInterestsForBoard(
portId: string,
filters: BoardFilters = {},
): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> {
// Kanban shows only active deals terminal (outcome-set) rows have
// Kanban shows only active deals - terminal (outcome-set) rows have
// their own /closed views. Pre-2026-05-14 this filter was just
// `archivedAt IS NULL`, which worked because setOutcome moved the
// stage to the 'completed' sentinel and the kanban only renders the
@@ -222,7 +222,7 @@ export async function listInterestsForBoard(
// Tag-id filter resolves through the join table first so the main
// query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT
// with LEFT JOIN keeps Postgres' planner happy at scale.
// with LEFT JOIN - keeps Postgres' planner happy at scale.
if (filters.tagIds && filters.tagIds.length > 0) {
const tagMatches = await db
.selectDistinct({ interestId: interestTags.interestId })
@@ -235,7 +235,7 @@ export async function listInterestsForBoard(
conditions.push(inArray(interests.id, matchingIds));
}
// Search hits client name via the LEFT JOIN. ILIKE is correct here
// Search hits client name via the LEFT JOIN. ILIKE is correct here -
// the kanban list is small (≤5000 rows) so an index scan isn't
// required, and pg_trgm would be overkill for the board surface.
if (filters.search && filters.search.trim().length > 0) {
@@ -520,7 +520,7 @@ export async function getInterestById(id: string, portId: string) {
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
// Total linked-berth count powers the "Berth Interest" milestone on
// the OverviewTab first thing the rep needs to capture, especially
// the OverviewTab - first thing the rep needs to capture, especially
// for general_interest leads. Resolved here (not from the join above)
// so the count includes berths the rep added without marking primary.
const [{ count: linkedBerthCount } = { count: 0 }] = await db
@@ -566,7 +566,7 @@ export async function getInterestById(id: string, portId: string) {
.from(reminders)
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
// Activity log entries in the last 7 days surfaces "rep is engaged"
// Activity log entries in the last 7 days - surfaces "rep is engaged"
// as a separate signal in the deal-health pulse beyond the coarse
// dateLastContact bump.
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000);
@@ -577,7 +577,7 @@ export async function getInterestById(id: string, portId: string) {
and(eq(interestContactLog.interestId, id), gte(interestContactLog.occurredAt, sevenDaysAgo)),
);
// Phase 2 risk-signal derivation. Three dates feed `computeDealHealth`
// Phase 2 - risk-signal derivation. Three dates feed `computeDealHealth`
// off the existing event tables so the pulse chip surfaces document
// declines / cancelled reservations / berth-resold-to-other without
// adding bespoke timestamp columns on `interests`. Each query runs in
@@ -606,7 +606,7 @@ export async function getInterestById(id: string, portId: string) {
.where(and(eq(berthReservations.interestId, id), eq(berthReservations.status, 'cancelled')))
.orderBy(desc(berthReservations.updatedAt))
.limit(1),
// "Berth sold to another deal" any of this interest's linked berths
// "Berth sold to another deal" - any of this interest's linked berths
// has at least one OTHER interest with a `won` outcome. Take the
// latest such outcome timestamp. archivedAt is a close proxy for the
// moment the win was finalised on the conflicting deal.
@@ -630,7 +630,7 @@ export async function getInterestById(id: string, portId: string) {
const dateDocumentDeclined = declinedRow[0]?.at ?? null;
const dateReservationCancelled = cancelledReservationRow[0]?.at ?? null;
// db.execute(sql`...`) returns either an array (postgres-js driver) or
// a `{rows: []}` object depending on driver build match the dual
// a `{rows: []}` object depending on driver build - match the dual
// shape used by src/lib/storage/migrate.ts.
const berthResoldRaw = berthResoldRow as unknown as
| Array<{ at: Date | null }>
@@ -640,7 +640,7 @@ export async function getInterestById(id: string, portId: string) {
: (berthResoldRaw.rows ?? []);
const dateBerthSoldToOther = berthResoldRows[0]?.at ?? null;
// Resolve the assignee's display name for the header chip falling back
// Resolve the assignee's display name for the header chip - falling back
// to the raw ID is fine if the user record is missing (deleted/disabled).
let assignedToName: string | null = null;
if (interest.assignedTo) {
@@ -656,7 +656,7 @@ export async function getInterestById(id: string, portId: string) {
...interest,
clientName: clientRow?.fullName ?? null,
clientPrimaryEmail: emailContact?.value ?? null,
/** Contact-row id for the primary email surfaces so the interest UI
/** Contact-row id for the primary email - surfaces so the interest UI
* can inline-edit through PATCH /api/v1/clients/[id]/contacts/[contactId]. */
clientPrimaryEmailContactId: emailContact?.id ?? null,
clientPrimaryPhone: phoneContact?.value ?? null,
@@ -673,7 +673,7 @@ export async function getInterestById(id: string, portId: string) {
activeReminderCount,
assignedToName,
recentActivityCount,
// Phase 2 risk-signal dates derived from event tables. Feed
// Phase 2 - risk-signal dates derived from event tables. Feed
// computeDealHealth so the pulse chip surfaces document declines,
// cancelled reservations, and "berth resold to another deal" without
// bespoke timestamp columns on the interest record.
@@ -705,7 +705,7 @@ export async function createInterest(portId: string, data: CreateInterestInput,
data.yachtId,
);
// Per-port reminder defaults applied only when the caller omitted
// Per-port reminder defaults - applied only when the caller omitted
// reminderEnabled / reminderDays. Honors the /admin/reminders page.
const reminderConfig = await getPortReminderConfig(portId);
const resolvedReminderEnabled = interestData.reminderEnabled ?? reminderConfig.defaultEnabled;
@@ -781,7 +781,7 @@ export async function createInterest(portId: string, data: CreateInterestInput,
}),
);
// Phase 6 CRM → Umami attribution. Fire an inbound-lead event so
// Phase 6 - CRM → Umami attribution. Fire an inbound-lead event so
// marketing can correlate inquiry volume with website traffic by
// source / referrer.
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
@@ -902,7 +902,7 @@ export async function updateInterest(
userId: data.assignedTo!,
type: 'interest_assigned',
title: 'New deal assigned to you',
description: `${clientLabel} ${existing.pipelineStage.replace(/_/g, ' ')}`,
description: `${clientLabel} - ${existing.pipelineStage.replace(/_/g, ' ')}`,
link: `/interests/${id}` as never,
entityType: 'interest',
entityId: id,
@@ -959,13 +959,13 @@ export async function changeInterestStage(
// Block egregious skips. The transition table allows reasonable forward
// jumps (e.g. enquiry → eoi) while rejecting things like contract → enquiry.
// Same-stage no-ops are allowed.
// Override (sales-rep manual fix) bypasses the table the route handler
// Override (sales-rep manual fix) bypasses the table - the route handler
// gates this on the `interests.override_stage` permission and requires
// a reason, recorded in the audit log below.
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
// F21: use the human-readable stage labels in error copy.
throw new ValidationError(
`Cannot move interest from "${STAGE_LABELS[existing.pipelineStage as PipelineStage] ?? existing.pipelineStage}" directly to "${STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage}". Use the override option if you need to skip stages requires a reason.`,
`Cannot move interest from "${STAGE_LABELS[existing.pipelineStage as PipelineStage] ?? existing.pipelineStage}" directly to "${STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage}". Use the override option if you need to skip stages - requires a reason.`,
);
}
if (data.override && (!data.reason || data.reason.trim().length < 5)) {
@@ -1010,7 +1010,7 @@ export async function changeInterestStage(
const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
const milestoneUpdates: Record<string, unknown> = {};
// For doc-bearing stages (eoi/reservation/contract) the milestone date is
// owned by the doc-send/sign flow, not the stage move these only fire
// owned by the doc-send/sign flow, not the stage move - these only fire
// when the rep stamps a date manually via override.
if (data.pipelineStage === 'eoi') milestoneUpdates.dateEoiSent = milestoneDate;
if (data.pipelineStage === 'reservation') milestoneUpdates.dateReservationSigned = milestoneDate;
@@ -1052,7 +1052,7 @@ export async function changeInterestStage(
}),
);
// Phase 6 CRM → Umami attribution for pipeline movement.
// Phase 6 - CRM → Umami attribution for pipeline movement.
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
trackEvent(portId, 'interest-stage-changed', {
interestId: id,
@@ -1153,7 +1153,7 @@ export async function advanceStageIfBehind(
* - 'off' → no-op (audit log of the event still fires upstream)
*
* Use this from every lifecycle event handler that wants admin-controlled
* cadence the bare `advanceStageIfBehind` stays available for paths
* cadence - the bare `advanceStageIfBehind` stays available for paths
* where the move is unconditional (manual rep action, completion of a
* doc the admin can't disable).
*/
@@ -1174,7 +1174,7 @@ export async function advanceStageIfBehindGated(
const mode = await getStageAdvanceMode(portId, trigger);
if (mode === 'off') return false;
if (mode === 'auto') return advanceStageIfBehind(interestId, portId, target, meta, reason);
// 'suggest' notify the rep with an Approve link, no auto-move. The
// 'suggest' - notify the rep with an Approve link, no auto-move. The
// rep can click the notification to fire the same advance manually.
const existing = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
@@ -1193,7 +1193,7 @@ export async function advanceStageIfBehindGated(
title: `Advance to ${target}?`,
description:
reason ??
`${trigger} fired suggested advance from ${existing.pipelineStage} to ${target}.`,
`${trigger} fired - suggested advance from ${existing.pipelineStage} to ${target}.`,
link: `/interests/${interestId}`,
entityType: 'interest',
entityId: interestId,
@@ -1211,7 +1211,7 @@ export async function advanceStageIfBehindGated(
// Records a terminal outcome for the interest. The `outcome` column is the
// canonical terminal-state signal; `pipelineStage` stays where it was so
// reports can answer "what stage was this deal at when it closed?". Prior to
// 2026-05-14 this method forced pipelineStage='completed' a sentinel
// 2026-05-14 this method forced pipelineStage='completed' - a sentinel
// outside the 7-stage canon that broke type narrowing + downstream stage
// label lookups. Active-interest queries filter by `outcome IS NULL` so
// the rep-facing kanban still hides closed deals.
@@ -1266,7 +1266,7 @@ export async function setInterestOutcome(
// via system_settings.berth_rules.
void evaluateRule('interest_completed', id, portId, meta);
// Phase 2 nested-subfolders rename the interest's document folder
// Phase 2 nested-subfolders - rename the interest's document folder
// to surface the outcome inline (e.g. "Deal A1-A3 (Won)"). Dynamic
// import avoids the circular dep with document-folders.service which
// already pulls from interests.service for the primary-berth label.
@@ -1277,10 +1277,10 @@ export async function setInterestOutcome(
: null,
)
.catch(() => {
// Folder may not exist yet (first upload happens later) silent.
// Folder may not exist yet (first upload happens later) - silent.
});
// Phase 6 CRM → Umami attribution. Fire a custom Umami event so
// Phase 6 - CRM → Umami attribution. Fire a custom Umami event so
// marketing can correlate inbound website traffic with the resulting
// deal outcome. Dynamic import to avoid a circular service dep at
// module-load time.
@@ -1316,7 +1316,7 @@ export async function clearInterestOutcome(
// - Else if the current stage is the legacy 'completed' sentinel,
// default to 'qualified' (closest analog of the pre-refactor
// 'in_communication' which would have lived there).
// - Else preserve the current stage post-refactor setOutcome stops
// - Else preserve the current stage - post-refactor setOutcome stops
// touching pipelineStage, so the deal already knows where it was
// when the rep closed it. Reopening should drop the rep back into
// that same column on the kanban.
@@ -1393,7 +1393,7 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet
// G-C4: fire the berth-rule (default mode 'suggest' for interest_archived).
// G-I2: notify sales of the next-in-line interests on the released berth so
// they can follow up mirrors the client-archive flow but scoped to a
// they can follow up - mirrors the client-archive flow but scoped to a
// single interest's primary berth.
if (primaryBerth) {
void evaluateRule('interest_archived', id, portId, meta);
@@ -1599,7 +1599,7 @@ export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) {
export async function getInterestStageCounts(portId: string) {
// Kanban / board counts surface active deals only (no terminal
// outcomes) terminal rows belong on a separate /closed surface.
// outcomes) - terminal rows belong on a separate /closed surface.
const rows = await db
.select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` })
.from(interests)