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 { 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>
|
||||
|
||||
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 ──────────────────────────────────────────
|
||||
* 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
|
||||
|
||||
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';
|
||||
|
||||
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',
|
||||
|
||||
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({
|
||||
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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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
|
||||
* 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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user