feat(berths): manual status catch-up wizard + reconciliation queue (#67)
Wires the long-dormant berths.status_override_mode column into a closed
loop so reps can reconcile berths flipped to under_offer/sold without a
backing interest.
Phase 1 — Status source tracking:
- updateBerthStatus() stamps 'manual' on every user-facing write
- berth-rules-engine.ts stamps 'automated' on auto-rule writes
- new clearBerthOverride() helper nulls the field and stamps the
reason "Reconciled via interest <id>" — only the wizard calls it
Phase 2 — Visual indicator:
- Amber "Manual" chip on berth-list rows where statusOverrideMode='manual'
AND no active linked interest (the candidates for catch-up)
Phase 3 — Reconciliation queue:
- new service listManualReconcileBerths() with cross-port-safe
NOT-EXISTS against activeInterestsWhere
- GET /api/v1/berths/reconcile-queue
- new page /[portSlug]/admin/berths/reconcile listing the queue,
each row linking to the catch-up wizard
Phase 4 — Catch-up wizard:
- POST /api/v1/berths/[id]/reconcile orchestrates create-client
(optional quick-create), create-interest with primary berth link,
and clearBerthOverride — composed via existing service helpers
- <CatchUpWizard> dialog: existing-client or quick-create, optional
yacht link, stage picker scoped to the current berth status, with
contract auto-setting outcome=won
Phase 5 — Entry points:
- sidebar Admin > "Reconcile berths" link
- berth-list row action menu shows "Catch up…" on flagged rows
Doc upload + payment recording (spec phases 4.4 / 4.5) are deferred —
once the interest exists, the rep uses the standard interest detail
page surfaces for those follow-ups. The wizard's MVP responsibility is
to take a manual berth to "interest exists, override cleared" in one
round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user