feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul

Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:59:28 +02:00
parent 267c2b6d1f
commit 3e4d9d6310
87 changed files with 5593 additions and 902 deletions

View File

@@ -0,0 +1,43 @@
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { type BreadcrumbHint, useBreadcrumbStore } from '@/stores/breadcrumb-store';
/**
* Detail pages call this on mount to register their entity hierarchy
* for the topbar breadcrumb. Pass a stable hint object (or memoise the
* inputs) so the effect doesn't re-fire every render.
*
* Example (interest detail page):
* useBreadcrumbHint({
* parents: [{ label: 'Mary Smith', href: '/port/clients/abc' }],
* current: 'B17',
* });
*
* The hint clears when the page unmounts so a stale hierarchy doesn't
* leak into the next route.
*/
export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void {
const pathname = usePathname();
const setHint = useBreadcrumbStore((s) => s.setHint);
const clearHint = useBreadcrumbStore((s) => s.clearHint);
// Stringify for stable equality — caller can pass an object literal
// each render without wrecking effect deps. The serialized form is
// tiny (handful of strings) so this is cheap.
const serialized = hint ? JSON.stringify(hint) : null;
useEffect(() => {
if (!serialized || !hint) return;
setHint(pathname, hint);
return () => {
clearHint(pathname);
};
// serialized stands in for `hint` value-equality; pathname triggers
// re-register if the page navigates without unmounting (rare but
// possible on client-side route swaps within the same layout).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialized, pathname, setHint, clearHint]);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type { TablePreferences, UserPreferences } from '@/lib/db/schema/users';
interface MeResponse {
data: {
preferences?: UserPreferences;
};
}
/**
* Reads + writes the current user's per-table column visibility, backed
* by `user_profiles.preferences.tablePreferences[entityType]`. Returns
* the hidden-columns set plus a `setHidden(next)` mutator.
*
* Writes are debounced (~600ms) so dragging a checkbox list back and
* forth doesn't spam the API. The local state is updated immediately
* for instant UX; the network round-trip is best-effort and silently
* swallowed on failure (preferences are recoverable from local state
* for the current session).
*
* `defaultHidden` applies ONLY when the user has never saved a
* preference for this entity (the stored entry is `undefined`). Once
* the user explicitly toggles any column, their saved list takes over
* — including the case where they've intentionally cleared it to an
* empty array (which means "show everything", overriding defaults).
*/
export function useTablePreferences(entityType: string, defaultHidden: string[] = []) {
const queryClient = useQueryClient();
const meQuery = useQuery<MeResponse>({
queryKey: ['me'],
queryFn: ({ signal }) => apiFetch<MeResponse>('/api/v1/me', { signal }),
staleTime: 5 * 60_000,
});
const remoteHidden =
meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns;
const [localHidden, setLocalHidden] = useState<string[] | null>(null);
// When the remote preferences arrive (or change), seed the local
// state. We only sync from remote → local on first load or when the
// server side genuinely changes (e.g. another tab updated prefs).
useEffect(() => {
if (remoteHidden && localHidden === null) {
setLocalHidden(remoteHidden);
}
}, [remoteHidden, localHidden]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setHidden = useCallback(
(next: string[]) => {
setLocalHidden(next);
// Debounce the PATCH so a user clicking through 5 checkboxes
// produces 1 server round-trip, not 5.
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const existing = meQuery.data?.data.preferences?.tablePreferences ?? {};
const updated: Record<string, TablePreferences> = {
...existing,
[entityType]: { hiddenColumns: next },
};
// Optimistic cache update so a refetch doesn't blow away the
// local state; the server response will overwrite either way.
queryClient.setQueryData<MeResponse>(['me'], (old) => {
if (!old) return old;
return {
...old,
data: {
...old.data,
preferences: {
...(old.data.preferences ?? {}),
tablePreferences: updated,
},
},
};
});
apiFetch('/api/v1/me', {
method: 'PATCH',
body: { preferences: { tablePreferences: updated } },
}).catch(() => {
// Network failures are non-fatal — the local UI keeps the
// chosen visibility for the rest of the session.
});
}, 600);
},
[entityType, meQuery.data, queryClient],
);
// Cleanup pending timer on unmount so React doesn't warn about
// setting state after the component is gone.
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
// Resolution order: local optimistic → remote saved → caller defaults.
// The `remoteHidden ?? defaultHidden` step is what gives us the "hide
// latestStage for fresh users, but respect their override once they
// touch it" behavior — saved value (even []) wins, defaults only fill
// the never-saved case.
const resolved = localHidden ?? remoteHidden ?? defaultHidden;
return {
hidden: resolved,
setHidden,
isLoaded: !meQuery.isLoading,
};
}