feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
EOI uploads from 'qualified' silently skipped the stage flip. Now also
writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
pdf/templates/{interest,client}-summary, interest-picker, timeline route
all route through canonicalizeStage / stageLabelFor.
Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
(deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
pipeline-column (kanban), interest-columns (list), interest-card,
interest-detail (breadcrumb), client-pipeline-summary +
client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.
Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
canonical BERTH_STATUSES); cleaned from dashboard.service,
dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
"Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
more 2-line wraps on "needs date range"); accepts initialRange?:
DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
rangeToBounds.
Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
berths (where the only active deal touching the berth IS this same
interest). Waits for all competing-queries before committing the
count. Was showing "3 berths unavailable" when only 1 actually had a
competitor.
Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
instead of firstAt so visible timestamp matches the sort key.
Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.
EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
one batched getAllBerthMooringsForInterests call across all groups.
AggregatedFile type + EntityFolderView render the badge linking back
to the parent interest.
External EOI upload dialog
- Title input pre-fills from the derived default via controlled
displayTitle = title || defaultTitle (no setState-in-effect).
EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
with tooltip: the primary IS the canonical "berth for this deal",
excluding it is semantically nonsense.
Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
whenever is_primary=true; update path coerces back to true when the
caller tries to set false on a primary. Backfilled 7 existing rows.
Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
documenso_redirect_url → public_site_url → null. Operators with
public_site_url configured (most ports) now get sensible signer
landing without setting two settings.
World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
filtered Clients page via router.push instead of copying a URL to
clipboard.
Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
folder has children. Lets reps drill into subfolders from the main
content area, not only via the sidebar tree.
Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
param). Interest list passes updatedAt desc so the table header
surfaces the active sort visibly + most-recently-added/edited bubble
to the top.
Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
— explicit input → port's default_new_interest_owner setting →
creator (when not super-admin). Super-admins skipped since they often
create on behalf of other reps.
Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
flipped to true.
Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed
Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,14 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
||||
return parts.join(' - ');
|
||||
}, [interestData, berthsData, signedAt]);
|
||||
|
||||
// The title input is controlled with `displayTitle` (derived from
|
||||
// either the rep's typed value or the auto-derived default). Reps
|
||||
// were treating the empty field as "this needs me to type something"
|
||||
// even though the placeholder showed a sensible default - now the
|
||||
// default is visible inside the input itself. Typing replaces the
|
||||
// default; clearing the field re-shows it.
|
||||
const displayTitle = title || defaultTitle;
|
||||
|
||||
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!file) throw new Error('No file selected');
|
||||
@@ -199,13 +207,13 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
||||
<div>
|
||||
<Label>Title (optional)</Label>
|
||||
<Input
|
||||
value={title}
|
||||
value={displayTitle}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={defaultTitle}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Leave blank to use the default shown above.
|
||||
Edit the auto-filled title or clear it to restore the default.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -84,10 +84,10 @@ export function InlineStagePicker({
|
||||
// interest's history, accessible via the activity timeline.
|
||||
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
|
||||
const [overrideReason, setOverrideReason] = useState('');
|
||||
// When dropping the stage back to 'open' on an interest with linked
|
||||
// When dropping the stage back to enquiry on an interest with linked
|
||||
// berths, prompt the rep whether to keep or unlink them. Going back to
|
||||
// open usually means restarting the lead, so the berth association is
|
||||
// often stale; offering a one-tap unlink prevents the public-map +
|
||||
// enquiry usually means restarting the lead, so the berth association
|
||||
// is often stale; offering a one-tap unlink prevents the public-map +
|
||||
// recommender from showing the berths as "under offer" for a dead deal.
|
||||
const [openConfirmTarget, setOpenConfirmTarget] = useState<PipelineStage | null>(null);
|
||||
const [unlinking, setUnlinking] = useState(false);
|
||||
@@ -97,7 +97,7 @@ export function InlineStagePicker({
|
||||
const stage = safeStage(currentStage);
|
||||
|
||||
// Fetch the linked-berth list lazily so we know whether to surface the
|
||||
// unlink-prompt when the rep drops the stage back to 'open'.
|
||||
// unlink-prompt when the rep drops the stage back to enquiry.
|
||||
const { data: linkedBerths } = useQuery<{ data: Array<{ berthId: string }> }>({
|
||||
queryKey: ['interest-berths', interestId, 'count-only'],
|
||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
|
||||
|
||||
@@ -80,11 +80,26 @@ export function InterestBerthStatusBanner({
|
||||
|
||||
if (conflicts.length === 0) return null;
|
||||
|
||||
const lines = conflicts.map((b, idx) => {
|
||||
const q = competingQueries[idx];
|
||||
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
|
||||
return { berth: b, competing };
|
||||
});
|
||||
// Wait for every per-berth competing-interest query to finish before
|
||||
// committing to a count - otherwise the banner briefly reads "3 berths"
|
||||
// and then shrinks to "1 berth" as queries land.
|
||||
const allCompetingLoaded = competingQueries.every((q) => !q.isLoading);
|
||||
if (!allCompetingLoaded) return null;
|
||||
|
||||
// A berth's status is 'under_offer' or 'sold' if ANY active interest -
|
||||
// including this one - flagged it as is_specific_interest. When this
|
||||
// interest is the only deal touching the berth, the conflict is
|
||||
// self-caused and shouldn't fire the banner: filter to berths where at
|
||||
// least one OTHER interest is in play.
|
||||
const lines = conflicts
|
||||
.map((b, idx) => {
|
||||
const q = competingQueries[idx];
|
||||
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
|
||||
return { berth: b, competing };
|
||||
})
|
||||
.filter((l) => l.competing !== null);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -100,24 +115,22 @@ export function InterestBerthStatusBanner({
|
||||
} to another deal.`
|
||||
: `${lines.length} linked berths are no longer freely available.`}
|
||||
</p>
|
||||
{lines.some((l) => l.competing) ? (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{lines.map(({ berth, competing }) =>
|
||||
competing ? (
|
||||
<li key={berth.id} className="text-rose-900">
|
||||
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{competing.clientName}
|
||||
</Link>
|
||||
</li>
|
||||
) : null,
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{lines.map(({ berth, competing }) =>
|
||||
competing ? (
|
||||
<li key={berth.id} className="text-rose-900">
|
||||
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{competing.clientName}
|
||||
</Link>
|
||||
</li>
|
||||
) : null,
|
||||
)}
|
||||
</ul>
|
||||
<p className="mt-0.5 text-rose-800">
|
||||
You can still progress this interest as a backup, but the rep on the other deal owns the
|
||||
primary path. If their deal falls through, this one can step in.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
formatSource,
|
||||
} from '@/lib/constants';
|
||||
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||
import type { InterestRow } from './interest-columns';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -52,7 +53,8 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
const urgencyBadges = computeUrgencyBadges(interest);
|
||||
|
||||
const clientName = interest.clientName ?? 'Unknown client';
|
||||
const berthLabel = interest.berthMooringNumber;
|
||||
const berthLabel =
|
||||
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
|
||||
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
|
||||
const lastActivity = lastIso
|
||||
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
||||
|
||||
export interface InterestRow {
|
||||
@@ -24,6 +25,10 @@ export interface InterestRow {
|
||||
yachtName?: string | null;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
/** Every linked berth's mooring (sorted) - drives the multi-berth list
|
||||
* cell label (`A1-A3, B5`). Falls back to `berthMooringNumber` alone
|
||||
* when empty/absent. */
|
||||
berthMoorings?: string[];
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
@@ -172,7 +177,9 @@ export function getInterestColumns({
|
||||
accessorKey: 'berthMooringNumber',
|
||||
header: 'Berth',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.berthId || !row.original.berthMooringNumber) {
|
||||
const label =
|
||||
deriveInterestBerthLabel(row.original.berthMoorings) ?? row.original.berthMooringNumber;
|
||||
if (!row.original.berthId || !label) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
@@ -181,7 +188,7 @@ export function getInterestColumns({
|
||||
className="text-primary hover:underline text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.berthMooringNumber}
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
||||
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatOutcome } from '@/lib/constants';
|
||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||
@@ -85,6 +86,10 @@ interface InterestDetailHeaderProps {
|
||||
activeReminderCount?: number;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
/** Every linked berth's mooring (sorted) - drives the multi-berth
|
||||
* header label (`Berth A1-A3, B5`). When absent or empty, falls back
|
||||
* to the primary mooring alone. */
|
||||
berthMoorings?: string[];
|
||||
yachtId: string | null;
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
@@ -184,8 +189,14 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
},
|
||||
});
|
||||
|
||||
// Multi-berth header label: prefer the full berth-range derived from
|
||||
// every linked mooring, fall back to the primary alone for older
|
||||
// payloads that haven't been re-fetched yet.
|
||||
const berthDisplayLabel =
|
||||
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
|
||||
|
||||
const meta: Array<{ key: string; node: React.ReactNode }> = [];
|
||||
if (interest.berthMooringNumber) {
|
||||
if (berthDisplayLabel) {
|
||||
meta.push({
|
||||
key: 'berth',
|
||||
node: (
|
||||
@@ -193,7 +204,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
href={`/${portSlug}/berths/${interest.berthId}`}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Berth {interest.berthMooringNumber}
|
||||
Berth {berthDisplayLabel}
|
||||
</Link>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||
|
||||
interface InterestData {
|
||||
id: string;
|
||||
@@ -44,6 +45,10 @@ interface InterestData {
|
||||
} | null;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
/** Every linked berth's mooring (sorted). Drives the multi-berth label
|
||||
* rendered in the breadcrumb + header. Falls back to the primary
|
||||
* mooring alone when empty/absent. */
|
||||
berthMoorings?: string[];
|
||||
/** Linked yacht - null until the rep ties one to the deal. Required to
|
||||
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
|
||||
yachtId: string | null;
|
||||
@@ -132,7 +137,8 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
data.clientId && data.clientName
|
||||
? [{ label: data.clientName, href: `/${portSlug}/clients/${data.clientId}` }]
|
||||
: [],
|
||||
current: data.berthMooringNumber ?? 'Interest',
|
||||
current:
|
||||
deriveInterestBerthLabel(data.berthMoorings) ?? data.berthMooringNumber ?? 'Interest',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -115,6 +115,13 @@ export function InterestList() {
|
||||
} = usePaginatedQuery<InterestRow>({
|
||||
queryKey: ['interests'],
|
||||
endpoint: '/api/v1/interests',
|
||||
// Surface the active sort visibly on the column header. The API
|
||||
// already defaults to updatedAt desc when no sort param is sent, but
|
||||
// without this the table renders with no active-sort indicator and
|
||||
// the rep can't tell what ordering is in play. Newly added / edited
|
||||
// deals bubble to the top of the list - the most useful default for
|
||||
// triage.
|
||||
initialSort: { field: 'updatedAt', direction: 'desc' },
|
||||
filterDefinitions: interestFilterDefinitions,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { stageLabelFor } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InterestOption {
|
||||
@@ -59,7 +60,7 @@ export function InterestPicker({
|
||||
if (!value) return placeholder;
|
||||
const match = options.find((o) => o.id === value);
|
||||
if (!match) return `Interest ${value.slice(0, 8)}`;
|
||||
if (match.clientName) return `${match.clientName} - ${match.pipelineStage ?? 'open'}`;
|
||||
if (match.clientName) return `${match.clientName} - ${stageLabelFor(match.pipelineStage)}`;
|
||||
return `Interest ${match.id.slice(0, 8)}`;
|
||||
})();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface InterestRow {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
berthMoorings?: string[];
|
||||
leadCategory: string | null;
|
||||
pipelineStage: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -5,11 +5,15 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||
|
||||
interface PipelineCardProps {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
/** Every linked berth's mooring (sorted) - drives the multi-berth label.
|
||||
* Falls back to `berthMooringNumber` alone when empty / absent. */
|
||||
berthMoorings?: string[];
|
||||
leadCategory: string | null;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
@@ -24,6 +28,7 @@ export function PipelineCard({
|
||||
id,
|
||||
clientName,
|
||||
berthMooringNumber,
|
||||
berthMoorings,
|
||||
leadCategory,
|
||||
updatedAt,
|
||||
}: PipelineCardProps) {
|
||||
@@ -38,6 +43,7 @@ export function PipelineCard({
|
||||
};
|
||||
|
||||
const daysInStage = differenceInDays(new Date(), new Date(updatedAt));
|
||||
const berthLabel = deriveInterestBerthLabel(berthMoorings) ?? berthMooringNumber;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -49,9 +55,7 @@ export function PipelineCard({
|
||||
>
|
||||
<p className="text-sm font-medium truncate">{clientName ?? 'Unknown client'}</p>
|
||||
|
||||
{berthMooringNumber && (
|
||||
<p className="text-xs text-muted-foreground">Berth: {berthMooringNumber}</p>
|
||||
)}
|
||||
{berthLabel && <p className="text-xs text-muted-foreground">Berth: {berthLabel}</p>}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{leadCategory && (
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ColumnItem {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
berthMoorings?: string[];
|
||||
leadCategory: string | null;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export function PipelineColumn({ stage, label, items }: PipelineColumnProps) {
|
||||
id={item.id}
|
||||
clientName={item.clientName}
|
||||
berthMooringNumber={item.berthMooringNumber}
|
||||
berthMoorings={item.berthMoorings}
|
||||
leadCategory={item.leadCategory}
|
||||
updatedAt={item.updatedAt}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user