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

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { apiFetch } from '@/lib/api/client';
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
interface ReconcileRow {
id: string;
mooringNumber: string;
area: string | null;
status: string;
statusLastChangedBy: string | null;
statusLastChangedReason: string | null;
statusLastModified: string | null;
}
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
function relativeAge(iso: string | null): string {
if (!iso) return '—';
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
export function ReconcileQueue() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [wizardBerthId, setWizardBerthId] = useState<string | null>(null);
const { data, isLoading } = useQuery<{ data: ReconcileRow[]; total: number }>({
queryKey: ['berths', 'reconcile-queue'],
queryFn: () => apiFetch('/api/v1/berths/reconcile-queue'),
});
if (isLoading) {
return (
<ul className="rounded-md border bg-white">
{[0, 1, 2].map((i) => (
<li key={i} className="h-14 animate-pulse border-b last:border-b-0 bg-muted/40" />
))}
</ul>
);
}
const rows = data?.data ?? [];
if (rows.length === 0) {
return (
<EmptyState
icon={<AlertTriangle className="h-7 w-7" aria-hidden />}
title="Nothing to reconcile"
body="Every berth that's been flipped manually has a backing interest. Manual status changes will show up here when there's no deal to explain them."
/>
);
}
return (
<>
<ul className="rounded-md border bg-white divide-y">
{rows.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 text-sm">
<div className="min-w-0 flex-1">
<Link
href={`/${portSlug}/berths/${encodeURIComponent(r.mooringNumber)}`}
className="font-medium text-foreground hover:text-brand"
>
{r.mooringNumber}
</Link>
{r.area ? <span className="ml-2 text-xs text-muted-foreground">{r.area}</span> : null}
{r.statusLastChangedReason ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{r.statusLastChangedReason}
</p>
) : null}
</div>
<StatusPill status={STATUS_PILL[r.status] ?? 'pending'}>
{STATUS_LABELS[r.status] ?? r.status}
</StatusPill>
<span className="text-xs tabular-nums text-muted-foreground w-20 text-right">
{relativeAge(r.statusLastModified)}
</span>
<Button size="sm" onClick={() => setWizardBerthId(r.id)} className="gap-1">
Catch up
<ArrowRight className="size-3.5" aria-hidden />
</Button>
</li>
))}
</ul>
<CatchUpWizard
berthId={wizardBerthId}
open={!!wizardBerthId}
onOpenChange={(o) => !o && setWizardBerthId(null)}
/>
</>
);
}