diff --git a/src/app/(dashboard)/[portSlug]/admin/berths/reconcile/page.tsx b/src/app/(dashboard)/[portSlug]/admin/berths/reconcile/page.tsx new file mode 100644 index 00000000..f8ddf957 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/berths/reconcile/page.tsx @@ -0,0 +1,14 @@ +import { PageHeader } from '@/components/shared/page-header'; +import { ReconcileQueue } from '@/components/admin/reconcile-queue'; + +export default function ReconcileBerthsPage() { + return ( +
+ + +
+ ); +} diff --git a/src/app/api/v1/berths/[id]/reconcile/route.ts b/src/app/api/v1/berths/[id]/reconcile/route.ts new file mode 100644 index 00000000..eb19a912 --- /dev/null +++ b/src/app/api/v1/berths/[id]/reconcile/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { reconcileBerthWithNewInterest } from '@/lib/services/berths.service'; +import { errorResponse } from '@/lib/errors'; +import { PIPELINE_STAGES } from '@/lib/constants'; + +const reconcileSchema = z + .object({ + clientId: z.string().uuid().optional(), + newClient: z + .object({ + fullName: z.string().trim().min(1).max(200), + email: z.string().email().optional(), + phone: z.string().trim().max(50).optional(), + }) + .optional(), + yachtId: z.string().uuid().optional(), + pipelineStage: z.enum(PIPELINE_STAGES as unknown as [string, ...string[]]), + outcome: z.enum(['won']).optional(), + outcomeReason: z.string().trim().max(500).optional(), + }) + .refine((v) => !!v.clientId || !!v.newClient?.fullName, { + message: 'Either clientId or newClient.fullName must be provided', + }); + +export const POST = withAuth( + withPermission('berths', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, reconcileSchema); + const result = await reconcileBerthWithNewInterest(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/berths/reconcile-queue/route.ts b/src/app/api/v1/berths/reconcile-queue/route.ts new file mode 100644 index 00000000..8c6fc102 --- /dev/null +++ b/src/app/api/v1/berths/reconcile-queue/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { listManualReconcileBerths } from '@/lib/services/berths.service'; +import { errorResponse } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('berths', 'edit', async (_req, ctx) => { + try { + const result = await listManualReconcileBerths(ctx.portId); + return NextResponse.json(result); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/reconcile-queue.tsx b/src/components/admin/reconcile-queue.tsx new file mode 100644 index 00000000..247ec574 --- /dev/null +++ b/src/components/admin/reconcile-queue.tsx @@ -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 = { + available: 'Available', + under_offer: 'Under Offer', + sold: 'Sold', +}; +const STATUS_PILL: Record = { + 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(null); + + const { data, isLoading } = useQuery<{ data: ReconcileRow[]; total: number }>({ + queryKey: ['berths', 'reconcile-queue'], + queryFn: () => apiFetch('/api/v1/berths/reconcile-queue'), + }); + + if (isLoading) { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • + ))} +
+ ); + } + + const rows = data?.data ?? []; + + if (rows.length === 0) { + return ( + } + 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 ( + <> +
    + {rows.map((r) => ( +
  • +
    + + {r.mooringNumber} + + {r.area ? {r.area} : null} + {r.statusLastChangedReason ? ( +

    + {r.statusLastChangedReason} +

    + ) : null} +
    + + {STATUS_LABELS[r.status] ?? r.status} + + + {relativeAge(r.statusLastModified)} + + +
  • + ))} +
+ !o && setWizardBerthId(null)} + /> + + ); +} diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 992464fe..0b34d89d 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -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 ( + + Manual + + ); +} + 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 ( - - - - - - { - e.stopPropagation(); - router.push(`/${params.portSlug}/berths/${berth.id}`); - }} - > - - View details - - { - e.stopPropagation(); - router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`); - }} - > - - Edit - - - + <> + + + + + + { + e.stopPropagation(); + router.push(`/${params.portSlug}/berths/${berth.id}`); + }} + > + + View details + + { + e.stopPropagation(); + router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`); + }} + > + + Edit + + {isManualUnreconciled ? ( + { + e.stopPropagation(); + setCatchUpOpen(true); + }} + > + + Catch up… + + ) : null} + + + {isManualUnreconciled ? ( + + ) : null} + ); } @@ -208,7 +254,16 @@ export const berthColumns: ColumnDef[] = [ id: 'status', accessorKey: 'status', header: 'Status', - cell: ({ row }) => , + cell: ({ row }) => { + const r = row.original; + const isManualUnreconciled = r.statusOverrideMode === 'manual' && !r.latestInterestStage; + return ( +
+ + {isManualUnreconciled ? : null} +
+ ); + }, }, { id: 'latestInterestStage', diff --git a/src/components/berths/catch-up-wizard.tsx b/src/components/berths/catch-up-wizard.tsx new file mode 100644 index 00000000..0495f94a --- /dev/null +++ b/src/components/berths/catch-up-wizard.tsx @@ -0,0 +1,276 @@ +'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 = { + 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 + * 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('existing'); + const [clientId, setClientId] = useState(null); + const [newClientName, setNewClientName] = useState(''); + const [newClientEmail, setNewClientEmail] = useState(''); + const [newClientPhone, setNewClientPhone] = useState(''); + const [yachtId, setYachtId] = useState(null); + const [pipelineStage, setPipelineStage] = useState('enquiry'); + + // 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 — + // 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'; + + // Keep selected stage in sync with the loaded berth's allowed set. + if (berth && pipelineStage !== defaultStage && !allowedStages.includes(pipelineStage)) { + setPipelineStage(defaultStage); + } + + const submit = useMutation({ + mutationFn: async () => { + if (!berthId) throw new Error('berthId missing'); + const body: Record = { 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'); + 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); + setPipelineStage('enquiry'); + } + + return ( + { + if (submit.isPending) return; + if (!o) reset(); + onOpenChange(o); + }} + > + + + Catch up berth {berth?.data.mooringNumber ?? ''} + + Create the backing interest so this berth drops out of the reconciliation queue. You can + attach documents and record payments from the new interest's detail page after + submission. + + + +
+
+ + setClientMode(v as ClientMode)} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+ {clientMode === 'existing' ? ( + + ) : ( +
+
+ + setNewClientName(e.target.value)} + placeholder="John Smith" + /> +
+
+
+ + setNewClientEmail(e.target.value)} + placeholder="client@example.com" + /> +
+
+ + setNewClientPhone(e.target.value)} + placeholder="+1 555 0100" + /> +
+
+
+ )} +
+ +
+ + +
+ +
+ + + {pipelineStage === 'contract' ? ( +

+ Stage Contract auto-marks the interest Won since + the berth is already flipped to Sold. +

+ ) : null} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 3b12a1e1..1a0a60ec 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -19,6 +19,7 @@ import { Settings, Shield, ScrollText, + RefreshCw, Home, ChevronLeft, ChevronRight, @@ -162,6 +163,13 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { { href: `${base}/admin`, label: 'Administration', icon: Shield }, // F14: audit log page existed but had no nav link. { href: `${base}/admin/audit`, label: 'Audit Log', icon: ScrollText }, + // #67 Phase 5: surfaces berths flipped manually without a backing + // interest so reps can run the catch-up wizard. + { + href: `${base}/admin/berths/reconcile`, + label: 'Reconcile berths', + icon: RefreshCw, + }, ], }, ]; diff --git a/src/lib/services/berth-rules-engine.ts b/src/lib/services/berth-rules-engine.ts index 285b94b9..ae515a63 100644 --- a/src/lib/services/berth-rules-engine.ts +++ b/src/lib/services/berth-rules-engine.ts @@ -161,6 +161,11 @@ export async function evaluateRule( statusLastChangedBy: meta.userId, statusLastChangedReason: `Auto-applied by rule: ${trigger}`, statusLastModified: new Date(), + // #67 Phase 1: stamp the source so the reconciliation queue + // can filter "Manual only" — rules-engine writes are never + // candidates for catch-up because they already have a backing + // interest driving them. + statusOverrideMode: 'automated', updatedAt: new Date(), }) .where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId))); diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index 39d4656d..49f416d0 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, gte, lte, inArray, isNull, notInArray, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths'; @@ -314,6 +314,11 @@ export async function updateBerthStatus( }); if (!existing) throw new NotFoundError('Berth'); + // #67 Phase 1: stamp the source of this write so the reconciliation + // queue (and the "Manual" chip on the row) can later distinguish a + // human-set status from a rules-engine auto-set status. The rules + // engine sets this to 'automated' on its own write path; user-facing + // API hits always end up here. const [updated] = await db .update(berths) .set({ @@ -321,6 +326,7 @@ export async function updateBerthStatus( statusLastChangedBy: meta.userId, statusLastChangedReason: data.reason, statusLastModified: new Date(), + statusOverrideMode: 'manual', updatedAt: new Date(), }) .where(and(eq(berths.id, id), eq(berths.portId, portId))) @@ -365,6 +371,206 @@ export async function updateBerthStatus( return updated!; } +// ─── Reconciliation Queue ───────────────────────────────────────────────────── +// +// #67 Phase 3: surfaces every berth whose status was set manually (i.e. +// statusOverrideMode === 'manual') AND that has no active linked interest +// backing the status change. These are the rows the catch-up wizard +// targets — a rep flipped them to under_offer / sold without ever +// creating the matching deal. Sorted by status_last_modified DESC so the +// freshest manual flips show up first. + +interface ReconcileRow { + id: string; + mooringNumber: string; + area: string | null; + status: string; + statusLastChangedBy: string | null; + statusLastChangedReason: string | null; + statusLastModified: Date | null; +} + +export async function listManualReconcileBerths(portId: string): Promise<{ + data: ReconcileRow[]; + total: number; +}> { + // Use a NOT EXISTS subquery against interest_berths joined with the active + // interests predicate so a berth currently linked to any open deal drops + // out of the queue — even if the rep set the status manually first and + // only later created the interest, that follow-up is the catch-up. + const activeBerthIds = db + .select({ berthId: interestBerths.berthId }) + .from(interestBerths) + .innerJoin(interests, eq(interestBerths.interestId, interests.id)) + .where(activeInterestsWhere(portId)); + + const rows = await db + .select({ + id: berths.id, + mooringNumber: berths.mooringNumber, + area: berths.area, + status: berths.status, + statusLastChangedBy: berths.statusLastChangedBy, + statusLastChangedReason: berths.statusLastChangedReason, + statusLastModified: berths.statusLastModified, + }) + .from(berths) + .where( + and( + eq(berths.portId, portId), + eq(berths.statusOverrideMode, 'manual'), + isNull(berths.archivedAt), + notInArray(berths.id, activeBerthIds), + ), + ) + .orderBy(desc(berths.statusLastModified)); + + return { data: rows, total: rows.length }; +} + +// ─── Reconcile Manual Override ──────────────────────────────────────────────── +// +// #67 Phase 1: called by the catch-up wizard once a backing interest is in +// place. Clears `statusOverrideMode` so the berth drops out of the +// reconciliation queue, and stamps the reason with the interest id so the +// audit trail records the reconciliation event explicitly. +// +// Intentionally NOT called from setPrimaryBerth/upsertInterestBerth — those +// run on every berth-link write (including drag-drop reorders that have +// nothing to do with a manual override) and would silently clear the flag +// behind the rep's back. Only the wizard owns the clear semantics. + +export async function clearBerthOverride( + berthId: string, + portId: string, + reconciledInterestId: string, + meta: AuditMeta, +): Promise { + const existing = await db.query.berths.findFirst({ + where: and(eq(berths.id, berthId), eq(berths.portId, portId)), + }); + if (!existing) throw new NotFoundError('Berth'); + + await db + .update(berths) + .set({ + statusOverrideMode: null, + statusLastChangedReason: `Reconciled via interest ${reconciledInterestId}`, + statusLastChangedBy: meta.userId, + statusLastModified: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(berths.id, berthId), eq(berths.portId, portId))); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'berth', + entityId: berthId, + oldValue: { statusOverrideMode: existing.statusOverrideMode ?? null }, + newValue: { statusOverrideMode: null, reconciledInterestId }, + metadata: { type: 'reconcile_manual', reconciledInterestId }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); +} + +// ─── Catch-up Reconcile ─────────────────────────────────────────────────────── +// +// #67 Phase 4: orchestrates "rep set the berth manually, now create the +// backing interest so the row drops out of the reconciliation queue". +// +// Intentionally a thin orchestrator over the existing client / interest +// service helpers (each of which already runs in its own transaction +// with its own audit-log emit). We pull them together here so the API +// layer has a single call to make, but the actual work stays inside the +// already-tested helpers — wrapping ALL of this in one transaction would +// require restructuring the audit-log emits to be queued + flushed at +// commit, which is out of scope for this feature. + +interface ReconcileBerthInput { + clientId?: string; + newClient?: { fullName: string; email?: string; phone?: string }; + yachtId?: string; + pipelineStage: string; + outcome?: 'won' | null; + outcomeReason?: string; +} + +export async function reconcileBerthWithNewInterest( + berthId: string, + portId: string, + input: ReconcileBerthInput, + meta: AuditMeta, +): Promise<{ interestId: string; clientId: string }> { + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, berthId), eq(berths.portId, portId)), + }); + if (!berth) throw new NotFoundError('Berth'); + if (berth.statusOverrideMode !== 'manual') { + throw new ValidationError('Berth is not in a manual-override state'); + } + + // Lazy imports so this module doesn't pull in the entire interest/client + // service surface (and create circular import chains). + const [{ createClient }, { createInterest }] = await Promise.all([ + import('@/lib/services/clients.service'), + import('@/lib/services/interests.service'), + ]); + + let clientId = input.clientId; + if (!clientId) { + if (!input.newClient?.fullName) { + throw new ValidationError('Either clientId or newClient.fullName is required'); + } + const contacts: Array<{ + channel: 'email' | 'phone' | 'whatsapp' | 'other'; + value: string; + isPrimary: boolean; + }> = []; + if (input.newClient.email) { + contacts.push({ channel: 'email', value: input.newClient.email.trim(), isPrimary: true }); + } + if (input.newClient.phone) { + contacts.push({ + channel: 'phone', + value: input.newClient.phone.trim(), + isPrimary: contacts.length === 0, + }); + } + const created = await createClient( + portId, + { + fullName: input.newClient.fullName.trim(), + contacts, + tagIds: [], + } as unknown as Parameters[1], + meta, + ); + clientId = created.id; + } + + const interest = await createInterest( + portId, + { + clientId, + yachtId: input.yachtId ?? null, + berthId, + pipelineStage: input.pipelineStage, + outcome: input.outcome ?? null, + outcomeReason: input.outcomeReason ?? null, + assignedTo: meta.userId, + tagIds: [], + } as unknown as Parameters[1], + meta, + ); + + await clearBerthOverride(berthId, portId, interest.id, meta); + + return { interestId: interest.id, clientId: clientId! }; +} + // ─── Set Tags ───────────────────────────────────────────────────────────────── export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {