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:
43
src/hooks/use-breadcrumb-hint.ts
Normal file
43
src/hooks/use-breadcrumb-hint.ts
Normal 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]);
|
||||
}
|
||||
119
src/hooks/use-table-preferences.ts
Normal file
119
src/hooks/use-table-preferences.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user