Files
pn-new-crm/src/components/berths/catch-up-wizard.tsx

277 lines
10 KiB
TypeScript
Raw Normal View History

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>
2026-05-14 23:55:22 +02:00
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ClientPicker } from '@/components/shared/client-picker';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { PIPELINE_STAGES, STAGE_LABELS } from '@/components/clients/pipeline-constants';
interface CatchUpWizardProps {
berthId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
type ClientMode = 'existing' | 'new';
interface BerthSummary {
id: string;
mooringNumber: string;
status: string;
}
const STATUS_TO_STAGES: Record<string, readonly string[]> = {
under_offer: ['enquiry', 'qualified', 'nurturing', 'eoi', 'reservation'],
sold: ['contract'],
available: PIPELINE_STAGES,
};
/**
* #67 Phase 4: catch-up wizard for manually-statused berths.
*
* MVP scope (intentionally tight):
* - Pick existing client OR quick-create with name + email/phone
* - Optional yacht link
* - Stage picker scoped to the current berth status (sold contract+won,
* under_offer enquiry...reservation, available any)
*
* Doc upload + payment recording (Phases 4.4 / 4.5 of the spec) are
* out of scope for the initial cut - once the interest exists, the rep
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>
2026-05-14 23:55:22 +02:00
* has the standard interest detail page to upload contracts and record
* payments. The wizard's job is to get them from "manual berth, no
* interest" to "interest exists, override cleared" in one round-trip.
*/
export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProps) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [clientMode, setClientMode] = useState<ClientMode>('existing');
const [clientId, setClientId] = useState<string | null>(null);
const [newClientName, setNewClientName] = useState('');
const [newClientEmail, setNewClientEmail] = useState('');
const [newClientPhone, setNewClientPhone] = useState('');
const [yachtId, setYachtId] = useState<string | null>(null);
fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep Knocks out 10 of the 13 known issues from yesterday's Playwright audit. A4 — Client form silently rejected submit when a contact row had an empty value. The F19 filter ran in mutationFn after zod's handleSubmit had already short-circuited on min(1). Now wraps the onSubmit to prune empty rows BEFORE handleSubmit/zod sees them. A16 — File upload to documents hub root 400'd because FormData.get returns null for absent fields and zod's .optional() rejects null. Route handler now coerces null/empty → undefined before parse. A17 — Added /api/v1/me/ports endpoint that any authenticated user can hit; client.ts now uses it as the bootstrap port-slug→port-id resolver. Eliminates the wasteful 400s sales-reps and viewers were firing on every page load against the super-admin-gated /admin/ports. A1 — Filter permission_denied actions from the dashboard activity feed. Still in the audit log; just not noise on the dashboard. A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor helpers in lib/constants. Activity-feed maps legacy 9-stage enum values (deposit_10pct, contract_sent, etc.) to their 7-stage labels on the way out, so historical audit rows read as "Deposit Paid" not "Deposit 10Pct". A19 — Same-stage write now returns 204 No Content. Service returns a STAGE_NOOP sentinel; the route handler translates it. A9 — Catch-up wizard now derives stage from berth status (under_offer → EOI, sold → contract) with a stageOverride state for explicit user picks. Avoids the set-state-in-effect rule violation. A20 — OwnerPicker shows a "Client / Company" hint chip on the trigger when no value is set, so users know the trigger opens a two-tab picker instead of just a client list. A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` to NULL so the column lives at strictly 3 states. A6 — file-preview-dialog gets a screen-reader DialogDescription so the Radix "Missing aria-describedby" warning stops firing on every preview. A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist (Next returns 404); /api/v1/admin/audit exists and 403s. A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate pass — both are dev-only cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
// A9: stageOverride is the user's explicit choice. When null, the
// effective stage derives from the loaded berth's status (under_offer
// → eoi, sold → contract). Pre-fix this was a useState seeded to
// 'enquiry' which never updated when the berth loaded.
const [stageOverride, setStageOverride] = useState<string | null>(null);
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>
2026-05-14 23:55:22 +02:00
// Fetch the berth so the wizard can scope the stage options to what
// makes sense for the current manual status. Disabled until open so
// closed-state hover/preview doesn't fire the request.
const { data: berth } = useQuery<{ data: BerthSummary }>({
queryKey: ['berth', berthId, 'catch-up-summary'],
queryFn: () => apiFetch(`/api/v1/berths/${berthId}`),
enabled: open && !!berthId,
});
const allowedStages = berth ? (STATUS_TO_STAGES[berth.data.status] ?? PIPELINE_STAGES) : [];
// Default the stage picker to the "right" default for each status -
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>
2026-05-14 23:55:22 +02:00
// sold defaults to contract (and we auto-set outcome=won server-side),
// under_offer defaults to eoi since that's the most common pre-deal
// status that reps mark manually.
const defaultStage = berth?.data.status === 'sold' ? 'contract' : 'eoi';
fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep Knocks out 10 of the 13 known issues from yesterday's Playwright audit. A4 — Client form silently rejected submit when a contact row had an empty value. The F19 filter ran in mutationFn after zod's handleSubmit had already short-circuited on min(1). Now wraps the onSubmit to prune empty rows BEFORE handleSubmit/zod sees them. A16 — File upload to documents hub root 400'd because FormData.get returns null for absent fields and zod's .optional() rejects null. Route handler now coerces null/empty → undefined before parse. A17 — Added /api/v1/me/ports endpoint that any authenticated user can hit; client.ts now uses it as the bootstrap port-slug→port-id resolver. Eliminates the wasteful 400s sales-reps and viewers were firing on every page load against the super-admin-gated /admin/ports. A1 — Filter permission_denied actions from the dashboard activity feed. Still in the audit log; just not noise on the dashboard. A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor helpers in lib/constants. Activity-feed maps legacy 9-stage enum values (deposit_10pct, contract_sent, etc.) to their 7-stage labels on the way out, so historical audit rows read as "Deposit Paid" not "Deposit 10Pct". A19 — Same-stage write now returns 204 No Content. Service returns a STAGE_NOOP sentinel; the route handler translates it. A9 — Catch-up wizard now derives stage from berth status (under_offer → EOI, sold → contract) with a stageOverride state for explicit user picks. Avoids the set-state-in-effect rule violation. A20 — OwnerPicker shows a "Client / Company" hint chip on the trigger when no value is set, so users know the trigger opens a two-tab picker instead of just a client list. A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` to NULL so the column lives at strictly 3 states. A6 — file-preview-dialog gets a screen-reader DialogDescription so the Radix "Missing aria-describedby" warning stops firing on every preview. A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist (Next returns 404); /api/v1/admin/audit exists and 403s. A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate pass — both are dev-only cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
const pipelineStage = stageOverride ?? defaultStage;
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>
2026-05-14 23:55:22 +02:00
const submit = useMutation({
mutationFn: async () => {
if (!berthId) throw new Error('berthId missing');
const body: Record<string, unknown> = { pipelineStage };
if (clientMode === 'existing') {
if (!clientId) throw new Error('Pick a client to continue');
body.clientId = clientId;
} else {
if (!newClientName.trim()) throw new Error('Enter the client name');
body.newClient = {
fullName: newClientName.trim(),
email: newClientEmail.trim() || undefined,
phone: newClientPhone.trim() || undefined,
};
}
if (yachtId) body.yachtId = yachtId;
if (pipelineStage === 'contract') body.outcome = 'won';
return apiFetch<{ data: { interestId: string; clientId: string } }>(
`/api/v1/berths/${berthId}/reconcile`,
{ method: 'POST', body },
);
},
onSuccess: (res) => {
toast.success('Berth reconciled - new interest created');
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>
2026-05-14 23:55:22 +02:00
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berths', 'reconcile-queue'] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
onOpenChange(false);
if (portSlug && res.data.interestId) {
router.push(`/${portSlug}/interests/${res.data.interestId}` as never);
}
},
onError: (err) => toastError(err),
});
function reset() {
setClientMode('existing');
setClientId(null);
setNewClientName('');
setNewClientEmail('');
setNewClientPhone('');
setYachtId(null);
fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep Knocks out 10 of the 13 known issues from yesterday's Playwright audit. A4 — Client form silently rejected submit when a contact row had an empty value. The F19 filter ran in mutationFn after zod's handleSubmit had already short-circuited on min(1). Now wraps the onSubmit to prune empty rows BEFORE handleSubmit/zod sees them. A16 — File upload to documents hub root 400'd because FormData.get returns null for absent fields and zod's .optional() rejects null. Route handler now coerces null/empty → undefined before parse. A17 — Added /api/v1/me/ports endpoint that any authenticated user can hit; client.ts now uses it as the bootstrap port-slug→port-id resolver. Eliminates the wasteful 400s sales-reps and viewers were firing on every page load against the super-admin-gated /admin/ports. A1 — Filter permission_denied actions from the dashboard activity feed. Still in the audit log; just not noise on the dashboard. A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor helpers in lib/constants. Activity-feed maps legacy 9-stage enum values (deposit_10pct, contract_sent, etc.) to their 7-stage labels on the way out, so historical audit rows read as "Deposit Paid" not "Deposit 10Pct". A19 — Same-stage write now returns 204 No Content. Service returns a STAGE_NOOP sentinel; the route handler translates it. A9 — Catch-up wizard now derives stage from berth status (under_offer → EOI, sold → contract) with a stageOverride state for explicit user picks. Avoids the set-state-in-effect rule violation. A20 — OwnerPicker shows a "Client / Company" hint chip on the trigger when no value is set, so users know the trigger opens a two-tab picker instead of just a client list. A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` to NULL so the column lives at strictly 3 states. A6 — file-preview-dialog gets a screen-reader DialogDescription so the Radix "Missing aria-describedby" warning stops firing on every preview. A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist (Next returns 404); /api/v1/admin/audit exists and 403s. A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate pass — both are dev-only cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
setStageOverride(null);
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>
2026-05-14 23:55:22 +02:00
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (submit.isPending) return;
if (!o) reset();
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Catch up berth {berth?.data.mooringNumber ?? ''}</DialogTitle>
<DialogDescription>
Create the backing interest so this berth drops out of the reconciliation queue. You can
attach documents and record payments from the new interest&apos;s detail page after
submission.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Client</Label>
<RadioGroup
value={clientMode}
onValueChange={(v) => setClientMode(v as ClientMode)}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="cu-client-existing" value="existing" />
<Label htmlFor="cu-client-existing" className="text-sm font-normal">
Pick existing
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="cu-client-new" value="new" />
<Label htmlFor="cu-client-new" className="text-sm font-normal">
Quick-create
</Label>
</div>
</RadioGroup>
{clientMode === 'existing' ? (
<ClientPicker value={clientId} onChange={setClientId} />
) : (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<div className="space-y-1">
<Label className="text-xs">Full name *</Label>
<Input
value={newClientName}
onChange={(e) => setNewClientName(e.target.value)}
placeholder="John Smith"
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Email</Label>
<Input
type="email"
value={newClientEmail}
onChange={(e) => setNewClientEmail(e.target.value)}
placeholder="client@example.com"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Phone</Label>
<Input
type="tel"
value={newClientPhone}
onChange={(e) => setNewClientPhone(e.target.value)}
placeholder="+1 555 0100"
/>
</div>
</div>
</div>
)}
</div>
<div className="space-y-1">
<Label>Linked yacht (optional)</Label>
<YachtPicker
value={yachtId}
onChange={setYachtId}
ownerFilter={
clientId && clientMode === 'existing' ? { type: 'client', id: clientId } : undefined
}
/>
</div>
<div className="space-y-1">
<Label>Pipeline stage</Label>
fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep Knocks out 10 of the 13 known issues from yesterday's Playwright audit. A4 — Client form silently rejected submit when a contact row had an empty value. The F19 filter ran in mutationFn after zod's handleSubmit had already short-circuited on min(1). Now wraps the onSubmit to prune empty rows BEFORE handleSubmit/zod sees them. A16 — File upload to documents hub root 400'd because FormData.get returns null for absent fields and zod's .optional() rejects null. Route handler now coerces null/empty → undefined before parse. A17 — Added /api/v1/me/ports endpoint that any authenticated user can hit; client.ts now uses it as the bootstrap port-slug→port-id resolver. Eliminates the wasteful 400s sales-reps and viewers were firing on every page load against the super-admin-gated /admin/ports. A1 — Filter permission_denied actions from the dashboard activity feed. Still in the audit log; just not noise on the dashboard. A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor helpers in lib/constants. Activity-feed maps legacy 9-stage enum values (deposit_10pct, contract_sent, etc.) to their 7-stage labels on the way out, so historical audit rows read as "Deposit Paid" not "Deposit 10Pct". A19 — Same-stage write now returns 204 No Content. Service returns a STAGE_NOOP sentinel; the route handler translates it. A9 — Catch-up wizard now derives stage from berth status (under_offer → EOI, sold → contract) with a stageOverride state for explicit user picks. Avoids the set-state-in-effect rule violation. A20 — OwnerPicker shows a "Client / Company" hint chip on the trigger when no value is set, so users know the trigger opens a two-tab picker instead of just a client list. A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` to NULL so the column lives at strictly 3 states. A6 — file-preview-dialog gets a screen-reader DialogDescription so the Radix "Missing aria-describedby" warning stops firing on every preview. A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist (Next returns 404); /api/v1/admin/audit exists and 403s. A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate pass — both are dev-only cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
<Select value={pipelineStage} onValueChange={setStageOverride}>
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>
2026-05-14 23:55:22 +02:00
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowedStages.map((s) => (
<SelectItem key={s} value={s}>
{STAGE_LABELS[s as keyof typeof STAGE_LABELS] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
{pipelineStage === 'contract' ? (
<p className="text-xs text-muted-foreground">
Stage <strong>Contract</strong> auto-marks the interest <strong>Won</strong> since
the berth is already flipped to Sold.
</p>
) : null}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={submit.isPending}
>
Cancel
</Button>
<Button type="button" onClick={() => submit.mutate()} disabled={submit.isPending}>
{submit.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" aria-hidden />}
Create interest & clear manual flag
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}