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:
117
src/components/admin/reconcile-queue.tsx
Normal file
117
src/components/admin/reconcile-queue.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user