feat(berths): manual status catch-up wizard + reconciliation queue (#67)

Wires the long-dormant berths.status_override_mode column into a closed
loop so reps can reconcile berths flipped to under_offer/sold without a
backing interest.

Phase 1 — Status source tracking:
  - updateBerthStatus() stamps 'manual' on every user-facing write
  - berth-rules-engine.ts stamps 'automated' on auto-rule writes
  - new clearBerthOverride() helper nulls the field and stamps the
    reason "Reconciled via interest <id>" — only the wizard calls it

Phase 2 — Visual indicator:
  - Amber "Manual" chip on berth-list rows where statusOverrideMode='manual'
    AND no active linked interest (the candidates for catch-up)

Phase 3 — Reconciliation queue:
  - new service listManualReconcileBerths() with cross-port-safe
    NOT-EXISTS against activeInterestsWhere
  - GET /api/v1/berths/reconcile-queue
  - new page /[portSlug]/admin/berths/reconcile listing the queue,
    each row linking to the catch-up wizard

Phase 4 — Catch-up wizard:
  - POST /api/v1/berths/[id]/reconcile orchestrates create-client
    (optional quick-create), create-interest with primary berth link,
    and clearBerthOverride — composed via existing service helpers
  - <CatchUpWizard> dialog: existing-client or quick-create, optional
    yacht link, stage picker scoped to the current berth status, with
    contract auto-setting outcome=won

Phase 5 — Entry points:
  - sidebar Admin > "Reconcile berths" link
  - berth-list row action menu shows "Catch up…" on flagged rows

Doc upload + payment recording (spec phases 4.4 / 4.5) are deferred —
once the interest exists, the rep uses the standard interest detail
page surfaces for those follow-ups. The wizard's MVP responsibility is
to take a manual berth to "interest exists, override cleared" in one
round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:55:22 +02:00
parent d2804de0d1
commit 7d33e73eef
9 changed files with 777 additions and 36 deletions

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Pencil, Activity } from 'lucide-react';
import { MoreHorizontal, Pencil, Activity, RefreshCw } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
@@ -16,6 +17,7 @@ import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { formatCurrency } from '@/lib/utils/currency';
import { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
export type BerthRow = {
id: string;
@@ -66,6 +68,11 @@ export type BerthRow = {
/** Most-advanced pipeline stage among the berth's active interests. Null
* when no active interest is linked. Read-only; computed server-side. */
latestInterestStage?: string | null;
/** #67: source of the last status write. 'manual' when a human set it
* via the API; 'automated' when a berth-rule fired; null on rows that
* haven't been touched since seed. The reconciliation surface treats
* 'manual' + no latestInterestStage as a row needing catch-up. */
statusOverrideMode?: string | null;
};
/**
@@ -133,45 +140,84 @@ function StatusBadge({ status }: { status: string }) {
);
}
/**
* #67 Phase 2: small amber chip beside the status pill flagging rows
* whose status was set manually and has no backing interest. These are
* the candidates for the catch-up wizard — the rep flipped a berth to
* "Under Offer" or "Sold" without ever creating the matching deal.
*/
function ManualBadge() {
return (
<span
className="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-800"
title="Status set manually with no backing interest — needs catch-up"
>
Manual
</span>
);
}
function ActionsCell({ row }: { row: { original: BerthRow } }) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const berth = row.original;
const [catchUpOpen, setCatchUpOpen] = useState(false);
const isManualUnreconciled = berth.statusOverrideMode === 'manual' && !berth.latestInterestStage;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${params.portSlug}/berths/${berth.id}`);
}}
>
<Activity className="mr-2 h-4 w-4" aria-hidden />
View details
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`);
}}
>
<Pencil className="mr-2 h-4 w-4" aria-hidden />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${params.portSlug}/berths/${berth.id}`);
}}
>
<Activity className="mr-2 h-4 w-4" aria-hidden />
View details
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`);
}}
>
<Pencil className="mr-2 h-4 w-4" aria-hidden />
Edit
</DropdownMenuItem>
{isManualUnreconciled ? (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setCatchUpOpen(true);
}}
>
<RefreshCw className="mr-2 h-4 w-4" aria-hidden />
Catch up
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{isManualUnreconciled ? (
<CatchUpWizard
berthId={catchUpOpen ? berth.id : null}
open={catchUpOpen}
onOpenChange={setCatchUpOpen}
/>
) : null}
</>
);
}
@@ -208,7 +254,16 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
cell: ({ row }) => {
const r = row.original;
const isManualUnreconciled = r.statusOverrideMode === 'manual' && !r.latestInterestStage;
return (
<div className="inline-flex items-center gap-1.5">
<StatusBadge status={r.status} />
{isManualUnreconciled ? <ManualBadge /> : null}
</div>
);
},
},
{
id: 'latestInterestStage',