Compare commits
5 Commits
3e78c2d4ab
...
58940552be
| Author | SHA1 | Date | |
|---|---|---|---|
| 58940552be | |||
| 202e0b1bc5 | |||
| 7d33e73eef | |||
| d2804de0d1 | |||
| 84468386d9 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,14 +10,14 @@ import { QueryProvider } from '@/providers/query-provider';
|
|||||||
import { SocketProvider } from '@/providers/socket-provider';
|
import { SocketProvider } from '@/providers/socket-provider';
|
||||||
import { PortProvider } from '@/providers/port-provider';
|
import { PortProvider } from '@/providers/port-provider';
|
||||||
import { PermissionsProvider } from '@/providers/permissions-provider';
|
import { PermissionsProvider } from '@/providers/permissions-provider';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { AppShell } from '@/components/layout/app-shell';
|
||||||
import { Topbar } from '@/components/layout/topbar';
|
|
||||||
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
|
||||||
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||||
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
||||||
|
import { classifyFormFactor } from '@/lib/form-factor';
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
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');
|
if (!session?.user) redirect('/login');
|
||||||
|
|
||||||
// Super admins have implicit access to every port; everyone else only sees
|
// 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 })
|
? await db.query.ports.findMany({ orderBy: portsTable.name })
|
||||||
: portRoles.map((pr) => pr.port);
|
: 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 (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||||
@@ -42,33 +48,18 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<RealtimeToasts />
|
<RealtimeToasts />
|
||||||
<WebVitalsReporter />
|
<WebVitalsReporter />
|
||||||
{/* Desktop shell - hidden by CSS on mobile */}
|
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
||||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
* mobile) per render — never both — so pages don't pay the
|
||||||
<Sidebar
|
* double-state, double-fetch, double-Tabs-provider tax. */}
|
||||||
portRoles={portRoles}
|
<AppShell
|
||||||
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
portRoles={portRoles}
|
||||||
user={{
|
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
||||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
user={user}
|
||||||
email: session.user.email,
|
ports={ports}
|
||||||
}}
|
initialFormFactor={initialFormFactor}
|
||||||
ports={ports}
|
>
|
||||||
/>
|
{children}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
</AppShell>
|
||||||
<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>
|
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
</PortProvider>
|
</PortProvider>
|
||||||
|
|||||||
44
src/app/api/v1/berths/[id]/reconcile/route.ts
Normal file
44
src/app/api/v1/berths/[id]/reconcile/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
16
src/app/api/v1/berths/reconcile-queue/route.ts
Normal file
16
src/app/api/v1/berths/reconcile-queue/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -319,35 +319,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Form-factor shell visibility ──────────────────────────────────────────
|
/* #26: dual-shell CSS visibility rules removed. AppShell now mounts a
|
||||||
* Two shells (desktop + mobile) render to the DOM on every page; CSS hides
|
* single responsive tree based on the server-classified form-factor +
|
||||||
* the inactive one. The data-form-factor body attribute is set server-side
|
* runtime matchMedia, so there is no inactive shell in the DOM to hide.
|
||||||
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
* The data-form-factor body attribute is still set on the root layout
|
||||||
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
* (downstream styles + analytics use it).
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
[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
|
* React Query Devtools floating button collides with the bottom tab bar's
|
||||||
|
|||||||
117
src/components/admin/reconcile-queue.tsx
Normal file
117
src/components/admin/reconcile-queue.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AlertTriangle, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
|
||||||
|
|
||||||
|
interface ReconcileRow {
|
||||||
|
id: string;
|
||||||
|
mooringNumber: string;
|
||||||
|
area: string | null;
|
||||||
|
status: string;
|
||||||
|
statusLastChangedBy: string | null;
|
||||||
|
statusLastChangedReason: string | null;
|
||||||
|
statusLastModified: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
available: 'Available',
|
||||||
|
under_offer: 'Under Offer',
|
||||||
|
sold: 'Sold',
|
||||||
|
};
|
||||||
|
const STATUS_PILL: Record<string, StatusPillStatus> = {
|
||||||
|
available: 'available',
|
||||||
|
under_offer: 'under_offer',
|
||||||
|
sold: 'sold',
|
||||||
|
};
|
||||||
|
|
||||||
|
function relativeAge(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||||
|
if (days <= 0) return 'today';
|
||||||
|
if (days === 1) return 'yesterday';
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||||
|
return `${Math.floor(days / 365)}y ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReconcileQueue() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const [wizardBerthId, setWizardBerthId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: ReconcileRow[]; total: number }>({
|
||||||
|
queryKey: ['berths', 'reconcile-queue'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/berths/reconcile-queue'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<ul className="rounded-md border bg-white">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<li key={i} className="h-14 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<AlertTriangle className="h-7 w-7" aria-hidden />}
|
||||||
|
title="Nothing to reconcile"
|
||||||
|
body="Every berth that's been flipped manually has a backing interest. Manual status changes will show up here when there's no deal to explain them."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul className="rounded-md border bg-white divide-y">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths/${encodeURIComponent(r.mooringNumber)}`}
|
||||||
|
className="font-medium text-foreground hover:text-brand"
|
||||||
|
>
|
||||||
|
{r.mooringNumber}
|
||||||
|
</Link>
|
||||||
|
{r.area ? <span className="ml-2 text-xs text-muted-foreground">{r.area}</span> : null}
|
||||||
|
{r.statusLastChangedReason ? (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{r.statusLastChangedReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<StatusPill status={STATUS_PILL[r.status] ?? 'pending'}>
|
||||||
|
{STATUS_LABELS[r.status] ?? r.status}
|
||||||
|
</StatusPill>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground w-20 text-right">
|
||||||
|
{relativeAge(r.statusLastModified)}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" onClick={() => setWizardBerthId(r.id)} className="gap-1">
|
||||||
|
Catch up
|
||||||
|
<ArrowRight className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<CatchUpWizard
|
||||||
|
berthId={wizardBerthId}
|
||||||
|
open={!!wizardBerthId}
|
||||||
|
onOpenChange={(o) => !o && setWizardBerthId(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { type ColumnDef } from '@tanstack/react-table';
|
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 { useRouter, useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 { formatCurrency } from '@/lib/utils/currency';
|
||||||
import { mooringLetterDot } from './mooring-letter-tone';
|
import { mooringLetterDot } from './mooring-letter-tone';
|
||||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
|
||||||
|
|
||||||
export type BerthRow = {
|
export type BerthRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -66,6 +68,11 @@ export type BerthRow = {
|
|||||||
/** Most-advanced pipeline stage among the berth's active interests. Null
|
/** Most-advanced pipeline stage among the berth's active interests. Null
|
||||||
* when no active interest is linked. Read-only; computed server-side. */
|
* when no active interest is linked. Read-only; computed server-side. */
|
||||||
latestInterestStage?: string | null;
|
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 } }) {
|
function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const berth = row.original;
|
const berth = row.original;
|
||||||
|
const [catchUpOpen, setCatchUpOpen] = useState(false);
|
||||||
|
const isManualUnreconciled = berth.statusOverrideMode === 'manual' && !berth.latestInterestStage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-8 w-8"
|
size="icon"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="h-8 w-8"
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
>
|
||||||
<span className="sr-only">Open menu</span>
|
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
||||||
</Button>
|
<span className="sr-only">Open menu</span>
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuContent align="end">
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
router.push(`/${params.portSlug}/berths/${berth.id}`);
|
e.stopPropagation();
|
||||||
}}
|
router.push(`/${params.portSlug}/berths/${berth.id}`);
|
||||||
>
|
}}
|
||||||
<Activity className="mr-2 h-4 w-4" aria-hidden />
|
>
|
||||||
View details
|
<Activity className="mr-2 h-4 w-4" aria-hidden />
|
||||||
</DropdownMenuItem>
|
View details
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={(e) => {
|
<DropdownMenuItem
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`);
|
e.stopPropagation();
|
||||||
}}
|
router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`);
|
||||||
>
|
}}
|
||||||
<Pencil className="mr-2 h-4 w-4" aria-hidden />
|
>
|
||||||
Edit
|
<Pencil className="mr-2 h-4 w-4" aria-hidden />
|
||||||
</DropdownMenuItem>
|
Edit
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
{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',
|
id: 'status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: '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',
|
id: 'latestInterestStage',
|
||||||
|
|||||||
276
src/components/berths/catch-up-wizard.tsx
Normal file
276
src/components/berths/catch-up-wizard.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -176,9 +176,30 @@ export function ClientForm({
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CreateClientInput) => {
|
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) {
|
if (isEdit) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// 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 });
|
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
|
||||||
if (tIds) {
|
if (tIds) {
|
||||||
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
|
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
|
||||||
@@ -187,7 +208,7 @@ export function ClientForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
|
await apiFetch('/api/v1/clients', { method: 'POST', body: payload });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -230,23 +251,12 @@ export function ClientForm({
|
|||||||
Basic Information
|
Basic Information
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="space-y-1">
|
||||||
<div className="sm:col-span-2 space-y-1">
|
<Label>Full Name *</Label>
|
||||||
<Label>Full Name *</Label>
|
<Input {...register('fullName')} placeholder="John Smith" />
|
||||||
<Input {...register('fullName')} placeholder="John Smith" />
|
{errors.fullName && (
|
||||||
{errors.fullName && (
|
<p className="text-xs text-destructive">{errors.fullName.message}</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -429,6 +439,14 @@ export function ClientForm({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label>Timezone</Label>
|
<Label>Timezone</Label>
|
||||||
<TimezoneCombobox
|
<TimezoneCombobox
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
|
||||||
|
|
||||||
@@ -96,11 +97,30 @@ function findInTree(nodes: FolderNode[], id: string): FolderNode | null {
|
|||||||
return 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) {
|
export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||||
// undefined = "All documents" (no folder selected / hub root)
|
// undefined = "All documents" (no folder selected / hub root)
|
||||||
// null = root folder only
|
// null = root folder only
|
||||||
// string = specific folder id
|
// 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();
|
const { data: tree = [] } = useDocumentFolders();
|
||||||
|
|
||||||
@@ -144,9 +164,20 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
selectedFolder.entityId != null &&
|
selectedFolder.entityId != null &&
|
||||||
isEntityType(folderEntityType);
|
isEntityType(folderEntityType);
|
||||||
|
|
||||||
const handleFolderSelect = (id: string | null | undefined) => {
|
const handleFolderSelect = useCallback(
|
||||||
setSelectedFolderId(id);
|
(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 = (
|
const sidebarFooter = (
|
||||||
<PermissionGate resource="documents" action="manage_folders">
|
<PermissionGate resource="documents" action="manage_folders">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from 'sonner';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -40,6 +41,14 @@ interface InlineStagePickerProps {
|
|||||||
/** Stop the parent's click propagation when used inside a clickable card. */
|
/** Stop the parent's click propagation when used inside a clickable card. */
|
||||||
stopPropagation?: boolean;
|
stopPropagation?: boolean;
|
||||||
className?: string;
|
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,
|
showChevron = true,
|
||||||
stopPropagation = false,
|
stopPropagation = false,
|
||||||
className,
|
className,
|
||||||
|
currentYachtId,
|
||||||
|
clientId,
|
||||||
}: InlineStagePickerProps) {
|
}: InlineStagePickerProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pendingStage, setPendingStage] = useState<string | null>(null);
|
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
|
// 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
|
// override permission), the popover transitions into a confirm view
|
||||||
// that asks for a reason before committing. Reasons are not exposed
|
// that asks for a reason before committing. Reasons are not exposed
|
||||||
@@ -130,6 +148,15 @@ export function InlineStagePicker({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
return;
|
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);
|
const isOverride = !canTransitionStage(stage, next);
|
||||||
if (isOverride && canOverride) {
|
if (isOverride && canOverride) {
|
||||||
// Switch into the confirm view rather than firing the mutation
|
// Switch into the confirm view rather than firing the mutation
|
||||||
@@ -143,6 +170,33 @@ export function InlineStagePicker({
|
|||||||
mutation.mutate({ next, reason: null });
|
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) {
|
async function unlinkAllAndOpen(target: PipelineStage) {
|
||||||
setUnlinking(true);
|
setUnlinking(true);
|
||||||
try {
|
try {
|
||||||
@@ -196,9 +250,12 @@ export function InlineStagePicker({
|
|||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(o) => {
|
onOpenChange={(o) => {
|
||||||
if (mutation.isPending) return;
|
if (mutation.isPending || linkingYacht) return;
|
||||||
setOpen(o);
|
setOpen(o);
|
||||||
if (!o) cancelOverride();
|
if (!o) {
|
||||||
|
cancelOverride();
|
||||||
|
cancelYachtPrereq();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -228,7 +285,57 @@ export function InlineStagePicker({
|
|||||||
className="w-72 p-0"
|
className="w-72 p-0"
|
||||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
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
|
// Confirm-override view: only reached when the user picked a
|
||||||
// stage that isn't a legal next step. Reason is optional but
|
// stage that isn't a legal next step. Reason is optional but
|
||||||
// strongly nudged for the audit log.
|
// strongly nudged for the audit log.
|
||||||
@@ -333,12 +440,12 @@ export function InlineStagePicker({
|
|||||||
) : isCurrent ? (
|
) : isCurrent ? (
|
||||||
<Check className="size-3.5 text-muted-foreground" aria-hidden />
|
<Check className="size-3.5 text-muted-foreground" aria-hidden />
|
||||||
) : isOverride && canOverride ? (
|
) : isOverride && canOverride ? (
|
||||||
<span
|
// F22: was ⚑ unicode glyph — replaced with a Lucide
|
||||||
className="text-[10px] uppercase tracking-wide text-amber-600"
|
// icon to match the rest of the visual system.
|
||||||
title="Override required"
|
<AlertTriangle
|
||||||
>
|
className="size-3.5 text-amber-600"
|
||||||
⚑
|
aria-label="Override required"
|
||||||
</span>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Archive,
|
Archive,
|
||||||
@@ -79,6 +80,7 @@ interface InterestDetailHeaderProps {
|
|||||||
activeReminderCount?: number;
|
activeReminderCount?: number;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
yachtId: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
@@ -144,6 +146,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
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
|
<InlineStagePicker
|
||||||
interestId={interest.id}
|
interestId={interest.id}
|
||||||
currentStage={interest.pipelineStage}
|
currentStage={interest.pipelineStage}
|
||||||
|
currentYachtId={interest.yachtId}
|
||||||
|
clientId={interest.clientId}
|
||||||
/>
|
/>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ interface InterestData {
|
|||||||
} | null;
|
} | null;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: 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
|
/** Yacht-fit dimensions (numeric strings from postgres). Drive the
|
||||||
* recommender panel guard ("Set desired dimensions to see recommendations"). */
|
* recommender panel guard ("Set desired dimensions to see recommendations"). */
|
||||||
desiredLengthFt: string | null;
|
desiredLengthFt: string | null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
|
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -85,6 +86,9 @@ interface InterestFormProps {
|
|||||||
|
|
||||||
export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) {
|
export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
const isEdit = !!interest;
|
const isEdit = !!interest;
|
||||||
|
|
||||||
const [clientOpen, setClientOpen] = useState(false);
|
const [clientOpen, setClientOpen] = useState(false);
|
||||||
@@ -220,13 +224,23 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
body: { tagIds: tIds },
|
body: { tagIds: tIds },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
return { id: interest!.id, created: false };
|
||||||
await apiFetch('/api/v1/interests', { method: 'POST', body: enriched });
|
|
||||||
}
|
}
|
||||||
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
onOpenChange(false);
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
71
src/components/layout/app-shell.tsx
Normal file
71
src/components/layout/app-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,17 +11,17 @@ import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
||||||
* bar. Renders only when CSS reveals it (data-shell="mobile") - both shells
|
* bar. Mounted by AppShell when the viewport classifies as mobile — never
|
||||||
* are in the DOM, see src/app/globals.css. The bottom tabs and More sheet
|
* concurrent with the desktop tree. The bottom tabs and More sheet derive
|
||||||
* derive the active port slug from the URL themselves, so this layout takes
|
* the active port slug from the URL themselves, so this layout takes no
|
||||||
* no portSlug prop.
|
* portSlug prop.
|
||||||
*/
|
*/
|
||||||
export function MobileLayout({ children }: { children: ReactNode }) {
|
export function MobileLayout({ children }: { children: ReactNode }) {
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-shell="mobile" className="min-h-[100dvh] bg-background">
|
<div className="min-h-[100dvh] bg-background">
|
||||||
<MobileLayoutProvider>
|
<MobileLayoutProvider>
|
||||||
<MobileTopbar />
|
<MobileTopbar />
|
||||||
<main
|
<main
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
RefreshCw,
|
||||||
Home,
|
Home,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -162,6 +163,13 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
{ href: `${base}/admin`, label: 'Administration', icon: Shield },
|
{ href: `${base}/admin`, label: 'Administration', icon: Shield },
|
||||||
// F14: audit log page existed but had no nav link.
|
// F14: audit log page existed but had no nav link.
|
||||||
{ href: `${base}/admin/audit`, label: 'Audit Log', icon: ScrollText },
|
{ 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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ export async function evaluateRule(
|
|||||||
statusLastChangedBy: meta.userId,
|
statusLastChangedBy: meta.userId,
|
||||||
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
|
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
|
||||||
statusLastModified: new Date(),
|
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(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
||||||
|
|||||||
@@ -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 { db } from '@/lib/db';
|
||||||
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||||
@@ -314,6 +314,11 @@ export async function updateBerthStatus(
|
|||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundError('Berth');
|
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
|
const [updated] = await db
|
||||||
.update(berths)
|
.update(berths)
|
||||||
.set({
|
.set({
|
||||||
@@ -321,6 +326,7 @@ export async function updateBerthStatus(
|
|||||||
statusLastChangedBy: meta.userId,
|
statusLastChangedBy: meta.userId,
|
||||||
statusLastChangedReason: data.reason,
|
statusLastChangedReason: data.reason,
|
||||||
statusLastModified: new Date(),
|
statusLastModified: new Date(),
|
||||||
|
statusOverrideMode: 'manual',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
|
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
|
||||||
@@ -365,6 +371,206 @@ export async function updateBerthStatus(
|
|||||||
return updated!;
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
|
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
|
||||||
|
|||||||
@@ -832,13 +832,22 @@ export async function changeInterestStage(
|
|||||||
throw new NotFoundError('Interest');
|
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
|
// Plan: yachtId required to leave the initial enquiry stage
|
||||||
if (
|
if (
|
||||||
existing.pipelineStage === 'enquiry' &&
|
existing.pipelineStage === 'enquiry' &&
|
||||||
data.pipelineStage !== 'enquiry' &&
|
data.pipelineStage !== 'enquiry' &&
|
||||||
!existing.yachtId
|
!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
|
// 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
|
// gates this on the `interests.override_stage` permission and requires
|
||||||
// a reason, recorded in the audit log below.
|
// a reason, recorded in the audit log below.
|
||||||
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
||||||
|
// F21: use the human-readable stage labels in error copy.
|
||||||
throw new ValidationError(
|
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)) {
|
if (data.override && (!data.reason || data.reason.trim().length < 5)) {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ describe('interests.service — yacht ownership validation', () => {
|
|||||||
{ pipelineStage: 'qualified' },
|
{ pipelineStage: 'qualified' },
|
||||||
makeAuditMeta({ portId: port.id }),
|
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 () => {
|
it('changeInterestStage succeeds when yachtId is set', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user