5 Commits

Author SHA1 Message Date
58940552be test: update yacht-prereq error message assertion to match F21 copy
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m12s
Build & Push Docker Images / build-and-push (push) Successful in 4m35s
The integration test was pinned to the legacy "yachtId is required before
leaving stage=enquiry" developer-language string. F21 reworded it to
"A yacht must be linked before leaving the Enquiry stage." for the toast
surface — bring the test regex along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:00:32 +02:00
202e0b1bc5 refactor(layout): single-tree responsive shell (#26)
Pre-fix the dashboard layout mounted BOTH the desktop and mobile shells
to the DOM on every page, hidden via CSS data-shell rules. Two Tabs
providers had data-state="active" concurrently, every fetch fired twice,
every component piece of state lived in two trees, a11y landmarks
duplicated, and half the click attempts hit the wrong layer.

New <AppShell> client wrapper mounts exactly ONE tree based on the
server-classified User-Agent (no hydration mismatch, no first-paint
flash on real mobile devices) plus a runtime matchMedia subscription
that swaps shells when the viewport crosses 1024px (e.g. desktop
browser resized).

Knock-on changes:
  - Dashboard layout fetches once and hands the data to AppShell;
    AppShell picks Desktop (Sidebar + Topbar + main) or MobileLayout
  - Stripped the now-orphan data-shell CSS rules from globals.css —
    nothing emits the attribute any more
  - MobileLayout drops its data-shell="mobile" attribute (was the lever
    the dead CSS rules pulled)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:59:30 +02:00
7d33e73eef 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
d2804de0d1 fix(ux): inline yacht-prereq picker + deprioritize country in client form
F23: when the rep tries to leave the Enquiry stage on an interest with no yacht linked, the stage popover now switches into an inline yacht-picker view (filtered to the client's own yachts when known). On submit it PATCHes interest.yachtId then chains the stage move, so the prereq fix and the advance happen in one flow instead of the rep bouncing to the validation error toast.
F24: Country moved out of the Basic Information section (next to Full Name *) into Source & Preferences alongside Timezone — country is timezone-hint material, not first-line identity data. Quick-path for a new client is now just name + contact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:46:36 +02:00
84468386d9 fix(ux): T4 polish wave — empty-contact filter, redirect-on-create, friendly stage errors
F19: client form drops empty-value contacts on submit; auto-promotes first remaining row to primary if none flagged.
F20: new-interest dialog redirects to the detail page on create instead of bouncing back to the list.
F21: stage-transition validation errors render with STAGE_LABELS — "Yacht is required before leaving the Enquiry stage." (was "yachtId is required before leaving stage=enquiry").
F22: blocked-stage marker swapped from the ⚑ unicode glyph to a Lucide AlertTriangle with aria-label.
F25: documents-hub folder selection moves to ?folder=<id> querystring so deep-link / browser-back / refresh round-trip the current folder.
F26: reopen-outcome action now toasts "Outcome cleared — interest is open again."
F27: stage PATCH where target === current short-circuits to a no-op return; downstream callers don't see a phantom stage_change audit row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:42:27 +02:00
21 changed files with 1108 additions and 138 deletions

View File

@@ -0,0 +1,14 @@
import { PageHeader } from '@/components/shared/page-header';
import { ReconcileQueue } from '@/components/admin/reconcile-queue';
export default function ReconcileBerthsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Berth reconciliation queue"
description="Berths flipped manually to Under Offer or Sold without a backing interest. Run the catch-up wizard on each row to create the deal, attach docs, and clear the manual flag."
/>
<ReconcileQueue />
</div>
);
}

View File

@@ -10,14 +10,14 @@ import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
import { AppShell } from '@/components/layout/app-shell';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
const headerList = await headers();
const session = await auth.api.getSession({ headers: headerList });
if (!session?.user) redirect('/login');
// Super admins have implicit access to every port; everyone else only sees
@@ -35,6 +35,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
? await db.query.ports.findMany({ orderBy: portsTable.name })
: portRoles.map((pr) => pr.port);
const initialFormFactor = classifyFormFactor(headerList.get('user-agent'));
const user = {
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
};
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
@@ -42,33 +48,18 @@ export default async function DashboardLayout({ children }: { children: React.Re
<SocketProvider>
<RealtimeToasts />
<WebVitalsReporter />
{/* Desktop shell - hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar
portRoles={portRoles}
isSuperAdmin={profile?.isSuperAdmin ?? false}
user={{
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
}}
ports={ports}
/>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar
ports={ports}
user={{
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
}}
/>
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">
{children}
</main>
</div>
</div>
{/* Mobile shell - hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout>
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render — never both — so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}
<AppShell
portRoles={portRoles}
isSuperAdmin={profile?.isSuperAdmin ?? false}
user={user}
ports={ports}
initialFormFactor={initialFormFactor}
>
{children}
</AppShell>
</SocketProvider>
</PermissionsProvider>
</PortProvider>

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -319,35 +319,12 @@
}
}
/* ─── Form-factor shell visibility ──────────────────────────────────────────
* Two shells (desktop + mobile) render to the DOM on every page; CSS hides
* the inactive one. The data-form-factor body attribute is set server-side
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* handles desktop browsers resized below lg (1024px), or stripped UAs.
*
* IMPORTANT: only `display: none` rules are emitted - we never set a positive
* display, because the desktop shell uses Tailwind's `flex` class which would
* be overridden by `display: block` (same specificity, later cascade).
/* #26: dual-shell CSS visibility rules removed. AppShell now mounts a
* single responsive tree based on the server-classified form-factor +
* runtime matchMedia, so there is no inactive shell in the DOM to hide.
* The data-form-factor body attribute is still set on the root layout
* (downstream styles + analytics use it).
*/
[data-shell='mobile'] {
display: none;
}
@media (max-width: 1023.98px) {
[data-shell='desktop'] {
display: none;
}
[data-shell='mobile'] {
display: block;
}
}
body[data-form-factor='mobile'] [data-shell='desktop'] {
display: none;
}
body[data-form-factor='mobile'] [data-shell='mobile'] {
display: block;
}
/*
* React Query Devtools floating button collides with the bottom tab bar's

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)}
/>
</>
);
}

View File

@@ -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',

View File

@@ -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<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
* 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);
const [pipelineStage, setPipelineStage] = useState<string>('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<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');
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 (
<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>
<Select value={pipelineStage} onValueChange={setPipelineStage}>
<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>
);
}

View File

@@ -176,9 +176,30 @@ export function ClientForm({
const mutation = useMutation({
mutationFn: async (data: CreateClientInput) => {
// F19: drop contact rows whose value is empty/whitespace before
// submitting. The form pre-adds an "empty primary" contact row
// for convenience; reps who only want to record a name shouldn't
// be forced to either fill it or delete it.
const cleanedContacts = (data.contacts ?? []).filter(
(c) => typeof c.value === 'string' && c.value.trim().length > 0,
);
if (cleanedContacts.length === 0) {
// The API still requires ≥1 contact. The form-level required
// marker on the email input also fires HTML5 validation; this
// is the fall-back if the rep wiped the value after focus.
throw Object.assign(new Error('At least one contact is required.'), { status: 400 });
}
// If none of the remaining contacts is flagged primary, promote
// the first one — guards against a rep removing the originally-
// primary row and leaving an orphan set.
if (!cleanedContacts.some((c) => c.isPrimary)) {
cleanedContacts[0]!.isPrimary = true;
}
const payload: CreateClientInput = { ...data, contacts: cleanedContacts };
if (isEdit) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { contacts, tagIds: tIds, ...rest } = data;
const { contacts, tagIds: tIds, ...rest } = payload;
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
if (tIds) {
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
@@ -187,7 +208,7 @@ export function ClientForm({
});
}
} else {
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
await apiFetch('/api/v1/clients', { method: 'POST', body: payload });
}
},
onSuccess: () => {
@@ -230,23 +251,12 @@ export function ClientForm({
Basic Information
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2 space-y-1">
<Label>Full Name *</Label>
<Input {...register('fullName')} placeholder="John Smith" />
{errors.fullName && (
<p className="text-xs text-destructive">{errors.fullName.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Country</Label>
<CountryCombobox
value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
data-testid="client-nationality"
/>
</div>
<div className="space-y-1">
<Label>Full Name *</Label>
<Input {...register('fullName')} placeholder="John Smith" />
{errors.fullName && (
<p className="text-xs text-destructive">{errors.fullName.message}</p>
)}
</div>
</div>
@@ -429,6 +439,14 @@ export function ClientForm({
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Country</Label>
<CountryCombobox
value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
data-testid="client-nationality"
/>
</div>
<div className="space-y-1">
<Label>Timezone</Label>
<TimezoneCombobox

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
@@ -96,11 +97,30 @@ function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
return null;
}
// URL encoding for the folder selection tri-state:
// no `folder` param → undefined (hub root / "All documents")
// `folder=root` → null (root folder only)
// `folder=<uuid>` → string (specific folder)
function decodeFolderParam(raw: string | null): string | null | undefined {
if (raw == null) return undefined;
if (raw === 'root') return null;
return raw;
}
function encodeFolderParam(value: string | null | undefined): string | null {
if (value === undefined) return null;
if (value === null) return 'root';
return value;
}
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
// undefined = "All documents" (no folder selected / hub root)
// null = root folder only
// string = specific folder id
const [selectedFolderId, setSelectedFolderId] = useState<string | null | undefined>(undefined);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const folderParam = searchParams.get('folder');
const selectedFolderId = useMemo(() => decodeFolderParam(folderParam), [folderParam]);
const { data: tree = [] } = useDocumentFolders();
@@ -144,9 +164,20 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
selectedFolder.entityId != null &&
isEntityType(folderEntityType);
const handleFolderSelect = (id: string | null | undefined) => {
setSelectedFolderId(id);
};
const handleFolderSelect = useCallback(
(id: string | null | undefined) => {
const next = new URLSearchParams(searchParams.toString());
const encoded = encodeFolderParam(id);
if (encoded == null) {
next.delete('folder');
} else {
next.set('folder', encoded);
}
const qs = next.toString();
router.replace((qs ? `${pathname}?${qs}` : pathname) as never, { scroll: false });
},
[router, pathname, searchParams],
);
const sidebarFooter = (
<PermissionGate resource="documents" action="manage_folders">

View File

@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import {
AlertDialog,
AlertDialogAction,
@@ -40,6 +41,14 @@ interface InlineStagePickerProps {
/** Stop the parent's click propagation when used inside a clickable card. */
stopPropagation?: boolean;
className?: string;
/** Current yacht linked to the interest (null if missing). When the rep
* tries to leave Enquiry without a yacht, the picker switches into an
* inline prereq view that lets them link a yacht and proceed in one
* flow instead of bouncing them out to the form. */
currentYachtId?: string | null;
/** Client owning the interest — scopes the inline yacht-picker so the
* rep only sees yachts that actually belong to this lead. */
clientId?: string;
}
/**
@@ -55,10 +64,19 @@ export function InlineStagePicker({
showChevron = true,
stopPropagation = false,
className,
currentYachtId,
clientId,
}: InlineStagePickerProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [pendingStage, setPendingStage] = useState<string | null>(null);
// F23: when the rep picks a non-Enquiry stage without a yacht linked,
// we drop into a yacht-picker view in the popover. yachtPrereqTarget
// holds the stage they were trying to reach; submitting the picker
// PATCHes the yachtId, then chains the original stage move.
const [yachtPrereqTarget, setYachtPrereqTarget] = useState<PipelineStage | null>(null);
const [yachtPrereqId, setYachtPrereqId] = useState<string | null>(null);
const [linkingYacht, setLinkingYacht] = useState(false);
// When a user picks a stage that isn't a legal next step (and has the
// override permission), the popover transitions into a confirm view
// that asks for a reason before committing. Reasons are not exposed
@@ -130,6 +148,15 @@ export function InlineStagePicker({
setOpen(false);
return;
}
// F23: leaving Enquiry without a yacht linked is the one hard prereq
// the service enforces. Instead of bouncing them to the validation
// error toast, drop into an inline yacht-picker so the rep can fix
// it in place. Same flow chains the stage move after the link.
if (stage === 'enquiry' && next !== 'enquiry' && !currentYachtId) {
setYachtPrereqTarget(next);
setYachtPrereqId(null);
return;
}
const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation
@@ -143,6 +170,33 @@ export function InlineStagePicker({
mutation.mutate({ next, reason: null });
}
async function commitYachtPrereq() {
if (!yachtPrereqTarget || !yachtPrereqId) return;
setLinkingYacht(true);
try {
await apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: { yachtId: yachtPrereqId },
});
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
const target = yachtPrereqTarget;
setYachtPrereqTarget(null);
setYachtPrereqId(null);
setPendingStage(target);
mutation.mutate({ next: target, reason: null });
} catch (err) {
toastError(err);
} finally {
setLinkingYacht(false);
}
}
function cancelYachtPrereq() {
setYachtPrereqTarget(null);
setYachtPrereqId(null);
}
async function unlinkAllAndOpen(target: PipelineStage) {
setUnlinking(true);
try {
@@ -196,9 +250,12 @@ export function InlineStagePicker({
<Popover
open={open}
onOpenChange={(o) => {
if (mutation.isPending) return;
if (mutation.isPending || linkingYacht) return;
setOpen(o);
if (!o) cancelOverride();
if (!o) {
cancelOverride();
cancelYachtPrereq();
}
}}
>
<PopoverTrigger asChild>
@@ -228,7 +285,57 @@ export function InlineStagePicker({
className="w-72 p-0"
onClick={(e) => stopPropagation && e.stopPropagation()}
>
{overrideTarget ? (
{yachtPrereqTarget ? (
// F23: inline yacht-prereq view — only reached when the rep
// picked a non-Enquiry stage without a yacht linked. Surfaces
// a yacht-picker right inside the popover so they can fix
// the prereq and move the stage in one flow.
<div className="p-3 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-600 mt-0.5" aria-hidden />
<div className="text-sm">
<p className="font-medium text-foreground">Yacht required</p>
<p className="text-xs text-muted-foreground">
A yacht must be linked before leaving Enquiry. Pick one below to move to{' '}
{STAGE_LABELS[yachtPrereqTarget]}.
</p>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Linked yacht</label>
<YachtPicker
value={yachtPrereqId}
onChange={(id) => setYachtPrereqId(id)}
ownerFilter={clientId ? { type: 'client', id: clientId } : undefined}
disabled={linkingYacht || mutation.isPending}
/>
</div>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelYachtPrereq}
disabled={linkingYacht || mutation.isPending}
className="gap-1"
>
<ChevronLeft className="size-3.5" aria-hidden />
Back
</Button>
<Button
type="button"
size="sm"
onClick={commitYachtPrereq}
disabled={!yachtPrereqId || linkingYacht || mutation.isPending}
>
{(linkingYacht || mutation.isPending) && (
<Loader2 className="size-3.5 animate-spin mr-1" aria-hidden />
)}
Link yacht & advance
</Button>
</div>
</div>
) : overrideTarget ? (
// Confirm-override view: only reached when the user picked a
// stage that isn't a legal next step. Reason is optional but
// strongly nudged for the audit log.
@@ -333,12 +440,12 @@ export function InlineStagePicker({
) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" aria-hidden />
) : isOverride && canOverride ? (
<span
className="text-[10px] uppercase tracking-wide text-amber-600"
title="Override required"
>
</span>
// F22: was ⚑ unicode glyph — replaced with a Lucide
// icon to match the rest of the visual system.
<AlertTriangle
className="size-3.5 text-amber-600"
aria-label="Override required"
/>
) : null}
</button>
</li>

View File

@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
Pencil,
Archive,
@@ -79,6 +80,7 @@ interface InterestDetailHeaderProps {
activeReminderCount?: number;
berthId: string | null;
berthMooringNumber: string | null;
yachtId: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
@@ -144,6 +146,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
// F26: confirm to the user that the action ran — pre-fix the
// button gave no feedback and reps weren't sure if it took.
toast.success('Outcome cleared — interest is open again.');
},
});
@@ -239,6 +244,8 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
<InlineStagePicker
interestId={interest.id}
currentStage={interest.pipelineStage}
currentYachtId={interest.yachtId}
clientId={interest.clientId}
/>
</PermissionGate>
)}

View File

@@ -43,6 +43,9 @@ interface InterestData {
} | null;
berthId: string | null;
berthMooringNumber: string | null;
/** Linked yacht — null until the rep ties one to the deal. Required to
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
yachtId: string | null;
/** Yacht-fit dimensions (numeric strings from postgres). Drive the
* recommender panel guard ("Set desired dimensions to see recommendations"). */
desiredLengthFt: string | null;

View File

@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -85,6 +86,9 @@ interface InterestFormProps {
export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) {
const queryClient = useQueryClient();
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const isEdit = !!interest;
const [clientOpen, setClientOpen] = useState(false);
@@ -220,13 +224,23 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
body: { tagIds: tIds },
});
}
} else {
await apiFetch('/api/v1/interests', { method: 'POST', body: enriched });
return { id: interest!.id, created: false };
}
const res = await apiFetch<{ data: { id: string } }>('/api/v1/interests', {
method: 'POST',
body: enriched,
});
return { id: res.data.id, created: true };
},
onSuccess: () => {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
onOpenChange(false);
// F20: navigate to the new interest's detail page so the rep can
// start the workflow immediately. Edits stay in place — no point
// re-loading the same row's detail page they just came from.
if (result.created && portSlug) {
router.push(`/${portSlug}/interests/${result.id}` as never);
}
},
});

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
type SidebarProps = ComponentProps<typeof Sidebar>;
type TopbarProps = ComponentProps<typeof Topbar>;
interface AppShellProps {
portRoles: SidebarProps['portRoles'];
isSuperAdmin: boolean;
user: NonNullable<SidebarProps['user']>;
ports: TopbarProps['ports'];
/**
* Server-rendered form-factor hint (from the request User-Agent). The
* shell mounts the matching tree on first render so we never paint the
* wrong shell, and only switches if the runtime viewport matchMedia
* disagrees (e.g. desktop browser resized below lg).
*/
initialFormFactor: 'mobile' | 'desktop';
children: ReactNode;
}
const MOBILE_QUERY = '(max-width: 1023.98px)';
/**
* #26: single-tree responsive shell. Pre-fix the layout mounted BOTH
* desktop and mobile shells in the DOM and CSS-hid one — doubling React
* state, fetches, Tabs providers, and a11y landmarks. AppShell decides
* once per render which tree to mount, so a page only ever runs the
* effects + queries it actually displays.
*
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
* the first client render uses the same value so hydration matches. After
* mount, a matchMedia subscription overrides if the viewport disagrees.
*/
export function AppShell({
portRoles,
isSuperAdmin,
user,
ports,
initialFormFactor,
children,
}: AppShellProps) {
const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile');
useEffect(() => {
const mq = window.matchMedia(MOBILE_QUERY);
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
if (isMobile) {
return <MobileLayout>{children}</MobileLayout>;
}
return (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar ports={ports} user={user} />
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">{children}</main>
</div>
</div>
);
}

View File

@@ -11,17 +11,17 @@ import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
/**
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
* bar. Renders only when CSS reveals it (data-shell="mobile") - both shells
* are in the DOM, see src/app/globals.css. The bottom tabs and More sheet
* derive the active port slug from the URL themselves, so this layout takes
* no portSlug prop.
* bar. Mounted by AppShell when the viewport classifies as mobile — never
* concurrent with the desktop tree. The bottom tabs and More sheet derive
* the active port slug from the URL themselves, so this layout takes no
* portSlug prop.
*/
export function MobileLayout({ children }: { children: ReactNode }) {
const [moreOpen, setMoreOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
return (
<div data-shell="mobile" className="min-h-[100dvh] bg-background">
<div className="min-h-[100dvh] bg-background">
<MobileLayoutProvider>
<MobileTopbar />
<main

View File

@@ -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,
},
],
},
];

View File

@@ -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)));

View File

@@ -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<void> {
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<typeof createClient>[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<typeof createInterest>[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) {

View File

@@ -832,13 +832,22 @@ export async function changeInterestStage(
throw new NotFoundError('Interest');
}
// F27: same-stage write is a no-op. Return the existing row without
// bumping updatedAt or emitting an audit log entry — pre-fix every
// re-submit (e.g. accidental double-click) wrote a "Same → Same"
// audit entry and triggered downstream invalidations.
if (existing.pipelineStage === data.pipelineStage) {
return existing;
}
// Plan: yachtId required to leave the initial enquiry stage
if (
existing.pipelineStage === 'enquiry' &&
data.pipelineStage !== 'enquiry' &&
!existing.yachtId
) {
throw new ValidationError('yachtId is required before leaving stage=enquiry');
// F21: user-readable; was "yachtId is required before leaving stage=enquiry"
throw new ValidationError('A yacht must be linked before leaving the Enquiry stage.');
}
// Block egregious skips. The transition table allows reasonable forward
@@ -848,8 +857,9 @@ export async function changeInterestStage(
// gates this on the `interests.override_stage` permission and requires
// a reason, recorded in the audit log below.
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
// F21: use the human-readable stage labels in error copy.
throw new ValidationError(
`Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`,
`Cannot move interest from "${STAGE_LABELS[existing.pipelineStage as PipelineStage] ?? existing.pipelineStage}" directly to "${STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`,
);
}
if (data.override && (!data.reason || data.reason.trim().length < 5)) {

View File

@@ -157,7 +157,7 @@ describe('interests.service — yacht ownership validation', () => {
{ pipelineStage: 'qualified' },
makeAuditMeta({ portId: port.id }),
),
).rejects.toThrow(/yachtId is required before leaving stage=enquiry/);
).rejects.toThrow(/yacht must be linked before leaving the Enquiry stage/i);
});
it('changeInterestStage succeeds when yachtId is set', async () => {