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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user