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:
2026-05-24 20:41:27 +02:00
parent 70d1e7e9b2
commit 41737fa950
47 changed files with 905 additions and 269 deletions

View File

@@ -22,13 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { AuditLogCard } from './audit-log-card';
@@ -149,10 +143,10 @@ export function AuditLogList() {
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
/** Currently-open audit detail row. Drives the side Sheet that
* exposes the full oldValue / newValue / metadata / IP / UA payload
* so reps can inspect a row without leaving the search list. */
const [detailEntry, setDetailEntry] = useState<AuditEntry | null>(null);
// Per-row detail is surfaced inline via a Popover anchored to the
// Details button (see column cell below). Lets the rep inspect the
// full oldValue / newValue / metadata / IP / UA payload without
// leaving the table or opening a Sheet.
const debouncedSearch = useDebounced(search);
const debouncedUserId = useDebounced(userId);
@@ -368,14 +362,76 @@ export function AuditLogList() {
Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent);
if (!hasDetail) return null;
return (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setDetailEntry(e)}
>
Details
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
Details
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
className="w-[420px] max-h-[60vh] overflow-y-auto p-3"
>
<div className="space-y-3 text-sm">
<div className="space-y-0.5">
<p className="font-semibold capitalize">
{e.action.replace(/_/g, ' ')} - {e.entityType}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(e.createdAt, 'datetime.medium')}
{e.actor ? ` · ${e.actor.name}` : ''}
</p>
</div>
{e.oldValue ? (
<details>
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(e.oldValue, null, 2)}
</pre>
</details>
) : null}
{e.newValue ? (
<details open>
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(e.newValue, null, 2)}
</pre>
</details>
) : null}
{e.metadata ? (
<details>
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(e.metadata, null, 2)}
</pre>
</details>
) : null}
{e.ipAddress || e.userAgent ? (
<dl className="grid grid-cols-[88px_1fr] gap-x-2 gap-y-1 text-[11px]">
{e.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{e.ipAddress}</dd>
</>
) : null}
{e.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="font-mono break-all">{e.userAgent}</dd>
</>
) : null}
</dl>
) : null}
</div>
</PopoverContent>
</Popover>
);
},
size: 80,
@@ -391,7 +447,7 @@ export function AuditLogList() {
variant="gradient"
/>
<div className="mt-4 flex flex-wrap items-end gap-3">
<div className="mt-4 flex flex-wrap items-end gap-x-4 gap-y-3">
<div className="space-y-1.5">
<Label htmlFor="audit-search" className="text-xs">
Search
@@ -533,7 +589,7 @@ export function AuditLogList() {
</Label>
<DatePicker
id="audit-from"
className="w-44 h-9"
className="w-52 h-9"
value={dateFrom}
onChange={setDateFrom}
/>
@@ -543,7 +599,7 @@ export function AuditLogList() {
<Label htmlFor="audit-to" className="text-xs">
To
</Label>
<DatePicker id="audit-to" className="w-44 h-9" value={dateTo} onChange={setDateTo} />
<DatePicker id="audit-to" className="w-52 h-9" value={dateTo} onChange={setDateTo} />
</div>
{/* M-AU03: CSV export inherits the current filter set. The
@@ -629,73 +685,6 @@ export function AuditLogList() {
</Button>
</div>
) : null}
<Sheet open={!!detailEntry} onOpenChange={(o) => !o && setDetailEntry(null)}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
{detailEntry ? (
<>
<SheetHeader>
<SheetTitle>
{detailEntry.action.replace(/_/g, ' ')} - {detailEntry.entityType}
</SheetTitle>
<SheetDescription>
{formatDate(detailEntry.createdAt, 'datetime.medium')}
{detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''}
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm">
{detailEntry.oldValue ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.oldValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.newValue ? (
<details open>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.newValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.metadata ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.metadata, null, 2)}
</pre>
</details>
) : null}
{detailEntry.ipAddress || detailEntry.userAgent ? (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1 text-xs">
{detailEntry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{detailEntry.ipAddress}</dd>
</>
) : null}
{detailEntry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="font-mono break-all">{detailEntry.userAgent}</dd>
</>
) : null}
</dl>
) : null}
</div>
</>
) : null}
</SheetContent>
</Sheet>
</div>
);
}