Compare commits
13 Commits
4dc0bdd8c4
...
5b9560531e
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b9560531e | |||
| f55be14813 | |||
| 6bc81270b9 | |||
| 38e392e38b | |||
| 039ef25fe5 | |||
| b3753b96a1 | |||
| 9147f2857e | |||
| 47778796ad | |||
| f7425d1231 | |||
| df8c26d1b3 | |||
| 91703bdb00 | |||
| 3165ec651f | |||
| 661187cc79 |
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||
import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({
|
||||
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||
icon: FileSearch,
|
||||
},
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
|
||||
label: 'Price reconciliation',
|
||||
description:
|
||||
'Parse the purchase price from each berth’s current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
|
||||
icon: BadgeDollarSign,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table';
|
||||
|
||||
export default function BerthPriceReconcilePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berth price reconciliation"
|
||||
eyebrow="ADMIN"
|
||||
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
|
||||
/>
|
||||
<BerthPriceReconcileTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
|
||||
|
||||
export default async function ClientGroupDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string; groupId: string }>;
|
||||
}) {
|
||||
const { groupId } = await params;
|
||||
return <ClientGroupDetail groupId={groupId} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
|
||||
|
||||
export default function ClientGroupsPage() {
|
||||
return <ClientGroupsList />;
|
||||
}
|
||||
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
|
||||
*
|
||||
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
|
||||
* integration tests can call it directly.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
approvals: z
|
||||
.array(
|
||||
z.object({
|
||||
berthId: z.string().min(1),
|
||||
price: z.number().nonnegative(),
|
||||
currency: z.string().min(1).max(8),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const postHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { postHandler } from './handlers';
|
||||
|
||||
export const POST = withAuth(withPermission('berths', 'edit', postHandler));
|
||||
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
|
||||
*
|
||||
* In handlers.ts so integration tests can call them directly, bypassing the
|
||||
* auth/permission middleware (per CLAUDE.md "Route handler exports").
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
|
||||
|
||||
export const getHandler: RouteHandler = async (_req, ctx) => {
|
||||
try {
|
||||
const data = await listPriceReconciliation(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('berths', 'edit', getHandler));
|
||||
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
archiveClientGroup,
|
||||
getClientGroupById,
|
||||
updateClientGroup,
|
||||
} from '@/lib/services/client-groups.service';
|
||||
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const group = await getClientGroupById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: group });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateClientGroupSchema);
|
||||
const updated = await updateClientGroup(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveClientGroup(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
|
||||
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const members = await listGroupMembers(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: members, total: members.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const { clientIds } = await parseBody(req, setGroupMembersSchema);
|
||||
await setGroupMembers(params.id!, ctx.portId, clientIds, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getMembersHandler, putMembersHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
|
||||
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));
|
||||
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));
|
||||
31
src/app/api/v1/client-groups/handlers.ts
Normal file
31
src/app/api/v1/client-groups/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
|
||||
import { createClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const groups = await listClientGroups(ctx.portId);
|
||||
return NextResponse.json({ data: groups, total: groups.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createClientGroupSchema);
|
||||
const group = await createClientGroup(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: group }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/route.ts
Normal file
6
src/app/api/v1/client-groups/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));
|
||||
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client');
|
||||
|
||||
export const GET = withAuth(withPermission('clients', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('clients', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler));
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
|
||||
*
|
||||
* Sets or clears the deal-specific price override for one (interest, berth).
|
||||
* In handlers.ts so integration tests can call it directly.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
price: z.number().nonnegative().nullable(),
|
||||
currency: z.string().min(1).max(8).optional(),
|
||||
});
|
||||
|
||||
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
|
||||
req,
|
||||
ctx,
|
||||
params,
|
||||
) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
await setBerthPriceOverride(
|
||||
params.id!,
|
||||
params.berthId!,
|
||||
body.price,
|
||||
body.currency ?? null,
|
||||
ctx.portId,
|
||||
);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { putHandler } from './handlers';
|
||||
|
||||
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest');
|
||||
|
||||
export const GET = withAuth(withPermission('interests', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler));
|
||||
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht');
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('yachts', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler));
|
||||
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Bulk berth price-reconcile table (CM-2 Part A).
|
||||
*
|
||||
* Lists the price parsed from each berth's current spec sheet next to the stored
|
||||
* price, with per-row + select-all approval. Nothing is written until the rep
|
||||
* approves — the apply mutation posts only the checked, changed rows.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
interface Row {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
currentPrice: number | null;
|
||||
currentCurrency: string;
|
||||
parsedPrice: number | null;
|
||||
parsedCurrency: string | null;
|
||||
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<Row['status'], string> = {
|
||||
changed: 'bg-amber-100 text-amber-800',
|
||||
matched: 'bg-muted text-muted-foreground',
|
||||
needs_review: 'bg-red-100 text-red-700',
|
||||
no_pdf: 'bg-slate-100 text-slate-500',
|
||||
};
|
||||
const STATUS_LABEL: Record<Row['status'], string> = {
|
||||
changed: 'Changed',
|
||||
matched: 'Matched',
|
||||
needs_review: 'Needs review',
|
||||
no_pdf: 'No PDF',
|
||||
};
|
||||
|
||||
const fmt = (n: number | null, ccy: string | null) =>
|
||||
n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim();
|
||||
|
||||
export function BerthPriceReconcileTable() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery<{ data: Row[] }>({
|
||||
queryKey: ['berths', 'price-reconcile'],
|
||||
queryFn: () => apiFetch('/api/v1/berths/price-reconcile'),
|
||||
});
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]);
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: async (): Promise<{ data: { updated: number } }> => {
|
||||
const approvals = selectable
|
||||
.filter((r) => checked[r.berthId] && r.parsedPrice != null)
|
||||
.map((r) => ({
|
||||
berthId: r.berthId,
|
||||
price: r.parsedPrice as number,
|
||||
currency: r.parsedCurrency ?? r.currentCurrency,
|
||||
}));
|
||||
return apiFetch('/api/v1/berths/price-reconcile/apply', {
|
||||
method: 'POST',
|
||||
body: { approvals },
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Updated ${res.data.updated} berth price(s).`);
|
||||
setChecked({});
|
||||
void qc.invalidateQueries({ queryKey: ['berths'] });
|
||||
},
|
||||
onError: (e: Error) => toastError(e),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets…</p>;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
|
||||
);
|
||||
}
|
||||
|
||||
const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]);
|
||||
const selectedCount = selectable.filter((r) => checked[r.berthId]).length;
|
||||
const reviewCount = rows.filter((r) => r.status === 'needs_review').length;
|
||||
const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selectedCount === 0 || apply.isPending}
|
||||
onClick={() => apply.mutate()}
|
||||
>
|
||||
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
|
||||
<th className="w-10 p-2 ps-3">
|
||||
<Checkbox
|
||||
aria-label="Select all changed"
|
||||
checked={allChecked}
|
||||
onCheckedChange={(c) =>
|
||||
setChecked(
|
||||
c === true
|
||||
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
|
||||
: {},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-2">Mooring</th>
|
||||
<th className="p-2">Area</th>
|
||||
<th className="p-2 text-end">Current</th>
|
||||
<th className="p-2 text-end">Parsed</th>
|
||||
<th className="p-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.berthId} className="border-b last:border-0">
|
||||
<td className="p-2 ps-3">
|
||||
{r.status === 'changed' ? (
|
||||
<Checkbox
|
||||
aria-label={`Approve ${r.mooringNumber}`}
|
||||
checked={!!checked[r.berthId]}
|
||||
onCheckedChange={(c) =>
|
||||
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-2 font-medium">{r.mooringNumber}</td>
|
||||
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
|
||||
<td className="p-2 text-end tabular-nums">
|
||||
{fmt(r.currentPrice, r.currentCurrency)}
|
||||
</td>
|
||||
<td className="p-2 text-end tabular-nums">
|
||||
{fmt(r.parsedPrice, r.parsedCurrency)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
{r.warning ? (
|
||||
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
src/components/client-groups/client-group-detail.tsx
Normal file
304
src/components/client-groups/client-group-detail.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface GroupMember {
|
||||
clientId: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
}
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
fullName: string;
|
||||
primaryEmail: string | null;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, successMsg: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMsg);
|
||||
} catch {
|
||||
toast.error('Copy failed — clipboard unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
export function ClientGroupDetail({ groupId }: { groupId: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
|
||||
queryKey: ['client-group', groupId],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
|
||||
});
|
||||
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
|
||||
queryKey: ['client-group', groupId, 'members'],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
|
||||
});
|
||||
|
||||
const group = groupResp?.data;
|
||||
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
|
||||
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Group archived');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
router.push(`/${portSlug}/client-groups` as Route);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/${portSlug}/client-groups` as Route}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
All groups
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={group?.name ?? 'Group'}
|
||||
eyebrow="Mailing group"
|
||||
kpiLine={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||
{emails.length < members.length ? (
|
||||
<span className="text-amber-700">
|
||||
· {members.length - emails.length} without email
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={emails.length === 0}
|
||||
onClick={() =>
|
||||
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
|
||||
}
|
||||
>
|
||||
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Copy all emails
|
||||
</Button>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button variant="outline" onClick={() => setManageOpen(true)}>
|
||||
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Manage members
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
|
||||
archive.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Archive
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading members…</p>
|
||||
) : members.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No members yet"
|
||||
description="Use “Manage members” to add clients to this group."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Client</th>
|
||||
<th className="px-4 py-2 font-medium">Email</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{members.map((m) => (
|
||||
<tr key={m.clientId} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${m.clientId}` as Route}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{m.fullName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-end">
|
||||
{m.email ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(m.email!, 'Email copied')}
|
||||
aria-label={`Copy ${m.email}`}
|
||||
title="Copy email"
|
||||
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manageOpen ? (
|
||||
<ManageMembersDialog
|
||||
groupId={groupId}
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
currentIds={members.map((m) => m.clientId)}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageMembersDialog({
|
||||
groupId,
|
||||
open,
|
||||
onOpenChange,
|
||||
currentIds,
|
||||
onSaved,
|
||||
}: {
|
||||
groupId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
currentIds: string[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
|
||||
queryKey: ['clients', 'group-picker'],
|
||||
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const clients = data?.data ?? [];
|
||||
const filtered = clients.filter((c) =>
|
||||
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
|
||||
method: 'PUT',
|
||||
body: { clientIds: Array.from(selected) },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Members updated');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tick the clients who belong in this group. {selected.size} selected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Search clients…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{isLoading ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">Loading clients…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
|
||||
{c.primaryEmail ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{c.primaryEmail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Save members
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
170
src/components/client-groups/client-groups-list.tsx
Normal file
170
src/components/client-groups/client-groups-list.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface ClientGroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export function ClientGroupsList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [color, setColor] = useState('#6B7280');
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
|
||||
queryKey: ['client-groups'],
|
||||
queryFn: () => apiFetch('/api/v1/client-groups'),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/client-groups', {
|
||||
method: 'POST',
|
||||
body: { name: name.trim(), description: description.trim() || null, color },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Group created');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setColor('#6B7280');
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const groups = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Client Groups"
|
||||
eyebrow="Mailing"
|
||||
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
|
||||
variant="gradient"
|
||||
actions={
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||
New group
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No groups yet"
|
||||
description="Create a group to start organising clients into mailing lists."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${portSlug}/client-groups/${g.id}` as Route}
|
||||
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: g.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
|
||||
</div>
|
||||
{g.description ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
|
||||
) : null}
|
||||
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New client group</DialogTitle>
|
||||
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-name">Name</Label>
|
||||
<Input
|
||||
id="cg-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Newsletter subscribers"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="cg-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-color">Color</Label>
|
||||
<input
|
||||
id="cg-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
|
||||
Create group
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
||||
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
||||
@@ -156,6 +157,9 @@ function OverviewTab({
|
||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||
</div>
|
||||
|
||||
{/* CM-9: point-of-contact (default level for the client). */}
|
||||
<ProxyCard entityType="client" entityId={clientId} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
||||
@@ -1133,6 +1134,9 @@ function OverviewTab({
|
||||
archivedAt={null}
|
||||
/>
|
||||
|
||||
{/* CM-9: per-deal point-of-contact (overrides the client's default). */}
|
||||
<ProxyCard entityType="interest" entityId={interestId} />
|
||||
|
||||
{/* Qualification checklist - surfaces the port's per-port criteria so
|
||||
the rep can mark each one confirmed before the deal advances out
|
||||
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
|
||||
addedBy: string | null;
|
||||
addedAt: string;
|
||||
notes: string | null;
|
||||
priceOverride: string | null;
|
||||
priceOverrideCurrency: string | null;
|
||||
mooringNumber: string | null;
|
||||
area: string | null;
|
||||
status: string;
|
||||
@@ -193,6 +195,24 @@ function useRemoveLink(interestId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// CM-2 Part B: set/clear the deal-specific price override for one berth.
|
||||
function useSetBerthPrice(interestId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { berthId: string; price: number | null }) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
|
||||
method: 'PUT',
|
||||
body: { price: args.price },
|
||||
}),
|
||||
onSuccess: (_data, args) => {
|
||||
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
|
||||
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
},
|
||||
onError: (e: Error) => toastError(e),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bypass dialog ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BypassDialogProps {
|
||||
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
|
||||
}: RowProps) {
|
||||
const [bypassOpen, setBypassOpen] = useState(false);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
|
||||
const setBerthPrice = useSetBerthPrice(interestId);
|
||||
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
|
||||
const showBypassControl = eoiStatus === 'signed';
|
||||
|
||||
const commitPrice = () => {
|
||||
const raw = priceDraft.replace(/[,\s]/g, '');
|
||||
const next = raw === '' ? null : Number(raw);
|
||||
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
|
||||
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
|
||||
if (next === prev) return;
|
||||
setBerthPrice.mutate({ berthId: row.berthId, price: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
|
||||
this interest only; flows into the EOI/document {{berth.price}} token. */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">Deal price</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overrides the berth's list price for this deal only. Leave blank to use the list
|
||||
price.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
|
||||
placeholder="List price"
|
||||
value={priceDraft}
|
||||
disabled={isPending || setBerthPrice.isPending}
|
||||
onChange={(e) => setPriceDraft(e.target.value)}
|
||||
onBlur={commitPrice}
|
||||
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
|
||||
/>
|
||||
{row.priceOverrideCurrency ? (
|
||||
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBypassControl ? (
|
||||
// Bypass section reads as a third toggle-style row: label + description
|
||||
// on the left, action button inline with the description so it doesn't
|
||||
|
||||
@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
|
||||
|
||||
/**
|
||||
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
|
||||
* deep-navy gradient surface with white type, the brand "PN" mark on the
|
||||
* left when there's no back affordance, and a soft glow shadow underneath
|
||||
* for depth instead of a hard divider line.
|
||||
* deep-navy gradient surface with white type, a back arrow on the left when
|
||||
* there's a back affordance (otherwise a balancing spacer), and a soft glow
|
||||
* shadow underneath for depth instead of a hard divider line.
|
||||
*
|
||||
* Slots: title (auto-truncating), back arrow, primary action - all driven by
|
||||
* `useMobileChrome()` from the active page. When no page has set a title the
|
||||
@@ -47,17 +47,6 @@ export function MobileTopbar() {
|
||||
portTitle ||
|
||||
'CRM';
|
||||
|
||||
// Brand-mark initials derived from the port slug
|
||||
// ("port-nimara" → "PN", "marina-alpha" → "MA"). Cheap, self-contained,
|
||||
// no extra DB round-trip.
|
||||
const initials = portSlug
|
||||
? portSlug
|
||||
.split('-')
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
: 'CR';
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -71,15 +60,10 @@ export function MobileTopbar() {
|
||||
{backTarget ? (
|
||||
<BackButton variant="mobile" />
|
||||
) : (
|
||||
<div
|
||||
aria-label={portTitle || 'Home'}
|
||||
className={cn(
|
||||
'size-9 shrink-0 rounded-lg flex items-center justify-center',
|
||||
'bg-[#3a7bc8] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25)]',
|
||||
)}
|
||||
>
|
||||
<span className="text-white font-bold text-[13px] tracking-tight">{initials}</span>
|
||||
</div>
|
||||
// No back affordance on top-level pages. Render an empty spacer the
|
||||
// same width as the right-hand action slot so the centered title
|
||||
// stays optically centered (the brand "PN" mark was removed here).
|
||||
<div className="size-11 shrink-0" aria-hidden />
|
||||
)}
|
||||
|
||||
<h1
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
UsersRound,
|
||||
Bookmark,
|
||||
Anchor,
|
||||
KeyRound,
|
||||
@@ -113,6 +114,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
items: [
|
||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||
{ href: `${base}/client-groups`, label: 'Client Groups', icon: UsersRound },
|
||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||
|
||||
249
src/components/shared/proxy-card.tsx
Normal file
249
src/components/shared/proxy-card.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mail, Phone, UserCheck, UserPlus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
type ProxyEntityType = 'client' | 'interest' | 'yacht';
|
||||
|
||||
interface Proxy {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
relationship: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
const RESOURCE: Record<ProxyEntityType, 'clients' | 'interests' | 'yachts'> = {
|
||||
client: 'clients',
|
||||
interest: 'interests',
|
||||
yacht: 'yachts',
|
||||
};
|
||||
|
||||
/**
|
||||
* CM-9: point-of-contact ("proxy") panel for a client / interest / yacht detail
|
||||
* page. Reads + edits the per-entity proxy via the entity's sub-resource route.
|
||||
*/
|
||||
export function ProxyCard({
|
||||
entityType,
|
||||
entityId,
|
||||
}: {
|
||||
entityType: ProxyEntityType;
|
||||
entityId: string;
|
||||
}) {
|
||||
const { can } = usePermissions();
|
||||
const canManage = can(RESOURCE[entityType], 'edit');
|
||||
const qc = useQueryClient();
|
||||
const base = `/api/v1/${RESOURCE[entityType]}/${entityId}/proxy`;
|
||||
const queryKey = ['proxy', entityType, entityId];
|
||||
|
||||
const { data } = useQuery<{ data: Proxy | null }>({
|
||||
queryKey,
|
||||
queryFn: () => apiFetch(base),
|
||||
});
|
||||
const proxy = data?.data ?? null;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiFetch(base, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Point of contact removed');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<UserCheck className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
Point of contact
|
||||
</h3>
|
||||
{canManage ? (
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setOpen(true)}>
|
||||
{proxy ? (
|
||||
'Edit'
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{proxy ? (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">
|
||||
{proxy.name}
|
||||
{proxy.relationship ? (
|
||||
<span className="ms-2 text-xs font-normal text-muted-foreground">
|
||||
{proxy.relationship}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{proxy.email ? (
|
||||
<a
|
||||
href={`mailto:${proxy.email}`}
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5" aria-hidden />
|
||||
{proxy.email}
|
||||
</a>
|
||||
) : null}
|
||||
{proxy.phone ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Phone className="h-3.5 w-3.5" aria-hidden />
|
||||
{proxy.phone}
|
||||
</p>
|
||||
) : null}
|
||||
{proxy.notes ? <p className="text-xs text-muted-foreground">{proxy.notes}</p> : null}
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate()}
|
||||
disabled={remove.isPending}
|
||||
className="pt-1 text-xs text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No proxy set — comms go to the {entityType} directly.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{open ? (
|
||||
<ProxyDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
base={base}
|
||||
existing={proxy}
|
||||
entityType={entityType}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
base,
|
||||
existing,
|
||||
entityType,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
base: string;
|
||||
existing: Proxy | null;
|
||||
entityType: ProxyEntityType;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(existing?.name ?? '');
|
||||
const [email, setEmail] = useState(existing?.email ?? '');
|
||||
const [phone, setPhone] = useState(existing?.phone ?? '');
|
||||
const [relationship, setRelationship] = useState(existing?.relationship ?? '');
|
||||
const [notes, setNotes] = useState(existing?.notes ?? '');
|
||||
// State seeds from `existing` at mount; the dialog is remounted on each open
|
||||
// (the parent renders it conditionally), so no reseed effect is needed.
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(base, {
|
||||
method: 'PUT',
|
||||
body: { name: name.trim(), email, phone, relationship, notes },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Point of contact saved');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Point of contact</DialogTitle>
|
||||
<DialogDescription>
|
||||
A person who acts as the point of contact for this {entityType}. Used to address
|
||||
outbound comms.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-name">Name</Label>
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-email">Email</Label>
|
||||
<Input
|
||||
id="proxy-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-phone">Phone</Label>
|
||||
<Input id="proxy-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-rel">Relationship (optional)</Label>
|
||||
<Input
|
||||
id="proxy-rel"
|
||||
placeholder="e.g. broker, spouse, assistant, legal"
|
||||
value={relationship}
|
||||
onChange={(e) => setRelationship(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-notes">Notes (optional)</Label>
|
||||
<Input id="proxy-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={!name.trim() || save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialo
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
|
||||
@@ -176,6 +177,10 @@ function OverviewTab({
|
||||
return (
|
||||
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* CM-9: per-vessel point-of-contact (overrides interest + client). */}
|
||||
<div className="md:col-span-2">
|
||||
<ProxyCard entityType="yacht" entityId={yachtId} />
|
||||
</div>
|
||||
{/* Identity */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||
|
||||
56
src/lib/api/proxy-route-handlers.ts
Normal file
56
src/lib/api/proxy-route-handlers.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* CM-9: shared GET/PUT/DELETE handlers for the per-entity proxy sub-resource
|
||||
* (`/api/v1/{clients|interests|yachts}/[id]/proxy`). Each entity's route.ts
|
||||
* binds these with its own permission resource so we reuse existing
|
||||
* clients/interests/yachts gating instead of a new permission.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { clearProxy, getProxy, setProxy } from '@/lib/services/proxies.service';
|
||||
import { setProxySchema, type ProxyEntityType } from '@/lib/validators/proxies';
|
||||
|
||||
export function makeProxyHandlers(entityType: ProxyEntityType) {
|
||||
const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const proxy = await getProxy(ctx.portId, entityType, params.id!);
|
||||
return NextResponse.json({ data: proxy });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
const putHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, setProxySchema);
|
||||
const proxy = await setProxy(ctx.portId, entityType, params.id!, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: proxy });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await clearProxy(ctx.portId, entityType, params.id!, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
return { getHandler, putHandler, deleteHandler };
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export const PERMISSION_CATALOG = {
|
||||
residential_clients: ['view', 'create', 'edit', 'delete'],
|
||||
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
||||
inquiries: ['view', 'manage'],
|
||||
client_groups: ['view', 'manage'],
|
||||
} as const satisfies {
|
||||
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
||||
};
|
||||
|
||||
52
src/lib/db/migrations/0094_client_groups.sql
Normal file
52
src/lib/db/migrations/0094_client_groups.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- 0094_client_groups.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- CM-1: first-class client groups (mailing/segment lists) + the membership
|
||||
-- join, plus the new `client_groups` permission resource (view/manage).
|
||||
--
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + a guarded role backfill.
|
||||
-- Safe to re-run.
|
||||
|
||||
-- ─── 1. client_groups (per-port named group) ────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS client_groups (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
color text NOT NULL DEFAULT '#6B7280',
|
||||
mailchimp_tag text,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_client_groups_port ON client_groups(port_id);
|
||||
-- Per-port, case-insensitive name uniqueness among non-archived groups.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_client_groups_port_name
|
||||
ON client_groups(port_id, lower(name))
|
||||
WHERE archived_at IS NULL;
|
||||
|
||||
-- ─── 2. client_group_members (M2M join; carries port_id for tenant isolation) ─
|
||||
CREATE TABLE IF NOT EXISTS client_group_members (
|
||||
group_id text NOT NULL REFERENCES client_groups(id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (group_id, client_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cgm_client ON client_group_members(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cgm_port ON client_group_members(port_id);
|
||||
|
||||
-- ─── 3. `client_groups` permission resource (view/manage) ────────────────────
|
||||
-- New-key only + idempotent via the `? 'client_groups'` guard. Defaults to the
|
||||
-- role's clients access (view ⟵ clients.view, manage ⟵ clients.create) so the
|
||||
-- right roles light up without a manual per-role edit.
|
||||
UPDATE roles
|
||||
SET permissions = permissions || jsonb_build_object(
|
||||
'client_groups', jsonb_build_object(
|
||||
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
|
||||
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
|
||||
)
|
||||
)
|
||||
WHERE permissions IS NOT NULL
|
||||
AND NOT (permissions ? 'client_groups');
|
||||
24
src/lib/db/migrations/0095_proxies.sql
Normal file
24
src/lib/db/migrations/0095_proxies.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- 0095_proxies.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- CM-9: per-entity point-of-contact ("proxy") attachable to a client, interest,
|
||||
-- or yacht. At most one per entity; outbound comms resolve the most specific
|
||||
-- via yacht → interest → client. entity_id is polymorphic (no FK; validated in
|
||||
-- the service against the right table). Idempotent — safe to re-run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proxies (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL,
|
||||
entity_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
email text,
|
||||
phone text,
|
||||
relationship text,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_proxies_entity ON proxies(port_id, entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_entity ON proxies(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proxies_port ON proxies(port_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- CM-2 Part B: per-interest, per-berth deal-price override.
|
||||
-- Null = use the berth's canonical list price (berths.price). When set, this
|
||||
-- supersedes the list price for THIS interest's generated documents
|
||||
-- (resolved in eoi-context via resolveBerthPriceForInterest).
|
||||
ALTER TABLE interest_berths
|
||||
ADD COLUMN IF NOT EXISTS price_override numeric,
|
||||
ADD COLUMN IF NOT EXISTS price_override_currency text;
|
||||
73
src/lib/db/schema/client-groups.ts
Normal file
73
src/lib/db/schema/client-groups.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Client groups (CM-1) - first-class mailing/segment groups for clients.
|
||||
*
|
||||
* A `client_groups` row is a named, per-port group (e.g. a mailing list).
|
||||
* `client_group_members` is the M2M join to `clients`. Membership carries its
|
||||
* own `port_id` for defense-in-depth tenant isolation (same doctrine as the
|
||||
* document-folders aggregated projection - port_id at every join).
|
||||
*
|
||||
* Optional Mailchimp mapping lives on the group row: `mailchimpTag` is the
|
||||
* tag/segment name pushed to the port's single Mailchimp audience. Null until
|
||||
* an admin wires Mailchimp up (the integration is inert without creds).
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { clients } from './clients';
|
||||
import { ports } from './ports';
|
||||
|
||||
export const clientGroups = pgTable(
|
||||
'client_groups',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
/** Chip color in the CRM UI. */
|
||||
color: text('color').notNull().default('#6B7280'),
|
||||
/** CM-1 Mailchimp: the tag/segment name this group maps to in the port's
|
||||
* single Mailchimp audience. Null = not synced. */
|
||||
mailchimpTag: text('mailchimp_tag'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_client_groups_port').on(table.portId),
|
||||
// Per-port, case-insensitive name uniqueness among non-archived groups.
|
||||
uniqueIndex('idx_client_groups_port_name')
|
||||
.on(table.portId, sql`lower(${table.name})`)
|
||||
.where(sql`${table.archivedAt} IS NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
export const clientGroupMembers = pgTable(
|
||||
'client_group_members',
|
||||
{
|
||||
groupId: text('group_id')
|
||||
.notNull()
|
||||
.references(() => clientGroups.id, { onDelete: 'cascade' }),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.groupId, table.clientId] }),
|
||||
index('idx_cgm_client').on(table.clientId),
|
||||
index('idx_cgm_port').on(table.portId),
|
||||
],
|
||||
);
|
||||
|
||||
export type ClientGroup = typeof clientGroups.$inferSelect;
|
||||
export type NewClientGroup = typeof clientGroups.$inferInsert;
|
||||
export type ClientGroupMember = typeof clientGroupMembers.$inferSelect;
|
||||
export type NewClientGroupMember = typeof clientGroupMembers.$inferInsert;
|
||||
@@ -7,6 +7,12 @@ export * from './users';
|
||||
// Clients
|
||||
export * from './clients';
|
||||
|
||||
// Client groups (CM-1 - mailing/segment groups)
|
||||
export * from './client-groups';
|
||||
|
||||
// Proxies / points-of-contact (CM-9 - polymorphic across client/interest/yacht)
|
||||
export * from './proxies';
|
||||
|
||||
// Companies
|
||||
export * from './companies';
|
||||
|
||||
|
||||
@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
|
||||
addedBy: text('added_by'),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
notes: text('notes'),
|
||||
// CM-2 Part B: deal-specific price for THIS (interest, berth). Null = use
|
||||
// the berth's canonical list price. Does not touch berths.price.
|
||||
priceOverride: numeric('price_override'),
|
||||
priceOverrideCurrency: text('price_override_currency'),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
||||
|
||||
48
src/lib/db/schema/proxies.ts
Normal file
48
src/lib/db/schema/proxies.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Proxies / points-of-contact (CM-9).
|
||||
*
|
||||
* A `proxy` is a designated contact person who acts on behalf of an entity.
|
||||
* Polymorphic: attachable to a `client` (default), an `interest` (per-deal
|
||||
* override), or a `yacht` (per-vessel override). At most one proxy per entity
|
||||
* (unique index). Outbound comms resolve the most specific proxy via the chain
|
||||
* yacht → interest → client (see resolveEffectiveProxy in proxies.service).
|
||||
*
|
||||
* `entity_id` is polymorphic (no FK) — validated against the right table in the
|
||||
* service, same pattern as polymorphic yacht ownership / notes.
|
||||
*/
|
||||
|
||||
import { index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ports } from './ports';
|
||||
|
||||
export const proxies = pgTable(
|
||||
'proxies',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
/** 'client' | 'interest' | 'yacht' */
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
/** Free-form relationship label, e.g. broker / spouse / assistant / legal. */
|
||||
relationship: text('relationship'),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
// At most one proxy per entity.
|
||||
uniqueIndex('uniq_proxies_entity').on(table.portId, table.entityType, table.entityId),
|
||||
index('idx_proxies_entity').on(table.entityType, table.entityId),
|
||||
index('idx_proxies_port').on(table.portId),
|
||||
],
|
||||
);
|
||||
|
||||
export type Proxy = typeof proxies.$inferSelect;
|
||||
export type NewProxy = typeof proxies.$inferInsert;
|
||||
@@ -166,6 +166,10 @@ export type RolePermissions = {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
};
|
||||
client_groups: {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,6 +92,10 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
@@ -175,6 +179,10 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
@@ -258,6 +266,10 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
@@ -341,6 +353,10 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
@@ -430,6 +446,10 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
view: true,
|
||||
manage: false,
|
||||
},
|
||||
client_groups: {
|
||||
view: true,
|
||||
manage: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Residential Partner - for an outside party who handles residential
|
||||
@@ -522,4 +542,8 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
view: false,
|
||||
manage: false,
|
||||
},
|
||||
client_groups: {
|
||||
view: false,
|
||||
manage: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -357,10 +357,14 @@ export function extractFromOcrText(rawText: string): {
|
||||
};
|
||||
}
|
||||
|
||||
// Purchase price: "PURCHASE PRICE:\nFEE SIMPLE OR STRATA LOT\n3,880,800 USD"
|
||||
const priceMatch = text.match(/PURCHASE\s+PRICE[\s\S]{0,80}?([0-9][0-9,]+)\s*USD/i);
|
||||
if (priceMatch) {
|
||||
out.price = { value: Number(priceMatch[1]!.replace(/,/g, '')), confidence: 0.7, engine: 'ocr' };
|
||||
// Purchase price: the single clean comma-grouped currency figure. The rates
|
||||
// on the same sheet are letter-spaced (garble) and below the floor, so they
|
||||
// never collide with the main price. See extractPurchasePrice().
|
||||
const priceResult = extractPurchasePrice(text);
|
||||
if (priceResult.value != null) {
|
||||
out.price = { value: priceResult.value, confidence: priceResult.confidence, engine: 'ocr' };
|
||||
} else if (priceResult.warning) {
|
||||
warnings.push(priceResult.warning);
|
||||
}
|
||||
|
||||
// Pricing validity: "ALL PRICES ABOVE ARE CONFIRMED THROUGH UNTIL SEPTEMBER 15TH, 2025"
|
||||
@@ -507,6 +511,62 @@ function coerceFieldValue(key: keyof ExtractedBerthFields, raw: string): string
|
||||
return numeric < 0 ? null : numeric;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor that separates a 6–7-figure purchase price from the ≤~12k weekly/daily
|
||||
* lease rates printed on the same sheet. Observed prices: 277,200 … 5,433,120;
|
||||
* observed weekly highs ≤ 11,341. A wide-margin separator.
|
||||
*/
|
||||
export const PURCHASE_PRICE_FLOOR = 50_000;
|
||||
|
||||
/**
|
||||
* Strict clean comma-grouped currency token. On the real spec sheets the
|
||||
* purchase price is the one figure rendered WITHOUT letter-spacing (the large
|
||||
* bold number); the weekly/daily rates ARE letter-spaced and garble in text
|
||||
* extraction, so they never match this pattern. The floor is a second guard
|
||||
* for clean/synthetic PDFs where rates would also extract cleanly.
|
||||
*/
|
||||
const PRICE_TOKEN_RE = /\b(\d{1,3}(?:,\d{3})+)\s?(USD|EUR|GBP)\b/gi;
|
||||
|
||||
/**
|
||||
* Extract the single main purchase price from raw PDF text. Returns
|
||||
* `value: null` (with a warning) when zero above-floor tokens are found, or
|
||||
* when two or more DISTINCT above-floor values appear (genuinely ambiguous —
|
||||
* flag for human review rather than guess).
|
||||
*/
|
||||
export function extractPurchasePrice(rawText: string): {
|
||||
value: number | null;
|
||||
currency: string | null;
|
||||
confidence: number;
|
||||
warning?: string;
|
||||
} {
|
||||
const candidates: Array<{ value: number; currency: string }> = [];
|
||||
for (const m of rawText.matchAll(PRICE_TOKEN_RE)) {
|
||||
const value = Number(m[1]!.replace(/,/g, ''));
|
||||
if (Number.isFinite(value) && value >= PURCHASE_PRICE_FLOOR) {
|
||||
candidates.push({ value, currency: m[2]!.toUpperCase() });
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
value: null,
|
||||
currency: null,
|
||||
confidence: 0,
|
||||
warning: 'No purchase-price token found (no clean figure ≥ floor).',
|
||||
};
|
||||
}
|
||||
const distinct = [...new Set(candidates.map((c) => c.value))];
|
||||
if (distinct.length > 1) {
|
||||
return {
|
||||
value: null,
|
||||
currency: null,
|
||||
confidence: 0,
|
||||
warning: `Multiple purchase-price candidates (${distinct.join(', ')}) — needs review.`,
|
||||
};
|
||||
}
|
||||
const best = candidates[0]!;
|
||||
return { value: best.value, currency: best.currency, confidence: 0.95 };
|
||||
}
|
||||
|
||||
/** Parse a human date like "September 15 2025" → "2025-09-15". */
|
||||
export function parseHumanDate(raw: string): string | null {
|
||||
const cleaned = raw.replace(/(\d+)(st|nd|rd|th)/i, '$1').trim();
|
||||
|
||||
166
src/lib/services/berth-price-reconcile.service.ts
Normal file
166
src/lib/services/berth-price-reconcile.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Bulk berth price reconciliation (CM-2 Part A).
|
||||
*
|
||||
* Re-parses each berth's CURRENT spec-sheet PDF (stored parseResults are
|
||||
* stale/wrong — the old purchase-price regex matched 0/113 real sheets),
|
||||
* surfaces old→new price diffs for an admin review page, and applies only the
|
||||
* rows a rep explicitly approves. Nothing is written until apply.
|
||||
*/
|
||||
|
||||
import pLimit from 'p-limit';
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths, berthPdfVersions } from '@/lib/db/schema/berths';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
import { parseBerthPdf, extractPurchasePrice } from './berth-pdf-parser';
|
||||
|
||||
export interface PriceReconcileRow {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
currentPrice: number | null;
|
||||
currentCurrency: string;
|
||||
parsedPrice: number | null;
|
||||
parsedCurrency: string | null;
|
||||
versionId: string | null;
|
||||
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: AsyncIterable<Buffer | string>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* For every active berth in the port, re-parse the current spec-sheet PDF and
|
||||
* report the parsed main price alongside the stored price. Tenant-scoped by
|
||||
* `portId`. Bounded concurrency keeps the S3/filesystem round-trips in check.
|
||||
*/
|
||||
export async function listPriceReconciliation(portId: string): Promise<PriceReconcileRow[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
berthId: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
currentPrice: berths.price,
|
||||
currentCurrency: berths.priceCurrency,
|
||||
versionId: berths.currentPdfVersionId,
|
||||
storageKey: berthPdfVersions.storageKey,
|
||||
})
|
||||
.from(berths)
|
||||
.leftJoin(berthPdfVersions, eq(berthPdfVersions.id, berths.currentPdfVersionId))
|
||||
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
|
||||
.orderBy(berths.mooringNumber);
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
const limit = pLimit(8);
|
||||
|
||||
return Promise.all(
|
||||
rows.map((r) =>
|
||||
limit(async (): Promise<PriceReconcileRow> => {
|
||||
const currentPrice = r.currentPrice == null ? null : Number(r.currentPrice);
|
||||
const base = {
|
||||
berthId: r.berthId,
|
||||
mooringNumber: r.mooringNumber,
|
||||
area: r.area,
|
||||
currentPrice,
|
||||
currentCurrency: r.currentCurrency,
|
||||
versionId: r.versionId,
|
||||
};
|
||||
if (!r.versionId || !r.storageKey) {
|
||||
return { ...base, parsedPrice: null, parsedCurrency: null, status: 'no_pdf' };
|
||||
}
|
||||
try {
|
||||
const buffer = await streamToBuffer(
|
||||
(await backend.get(r.storageKey)) as AsyncIterable<Buffer | string>,
|
||||
);
|
||||
const parse = await parseBerthPdf(buffer);
|
||||
const price = extractPurchasePrice(parse.rawText ?? '');
|
||||
if (price.value == null) {
|
||||
return {
|
||||
...base,
|
||||
parsedPrice: null,
|
||||
parsedCurrency: null,
|
||||
status: 'needs_review',
|
||||
warning: price.warning,
|
||||
};
|
||||
}
|
||||
const status = currentPrice === price.value ? 'matched' : 'changed';
|
||||
return { ...base, parsedPrice: price.value, parsedCurrency: price.currency, status };
|
||||
} catch (err) {
|
||||
logger.warn({ berthId: r.berthId, err }, 'price-reconcile: parse failed');
|
||||
return {
|
||||
...base,
|
||||
parsedPrice: null,
|
||||
parsedCurrency: null,
|
||||
status: 'needs_review',
|
||||
warning: 'PDF could not be parsed.',
|
||||
};
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a rep-approved slice of parsed prices to `berths.price`/`priceCurrency`.
|
||||
* Tenant-scoped: cross-port berth ids are silently skipped (defense in depth on
|
||||
* top of the route's permission gate). Stamps each berth's current PDF version
|
||||
* `parseResults.bulkPriceApplied` for audit.
|
||||
*/
|
||||
export async function applyBulkBerthPrices(
|
||||
portId: string,
|
||||
approvals: Array<{ berthId: string; price: number; currency: string }>,
|
||||
actingUserId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
if (approvals.length === 0) return { updated: 0 };
|
||||
const ids = approvals.map((a) => a.berthId);
|
||||
const owned = await db
|
||||
.select({ id: berths.id, vid: berths.currentPdfVersionId })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), inArray(berths.id, ids)));
|
||||
const ownedVid = new Map(owned.map((b) => [b.id, b.vid]));
|
||||
|
||||
let updated = 0;
|
||||
await db.transaction(async (tx) => {
|
||||
for (const a of approvals) {
|
||||
if (!ownedVid.has(a.berthId)) continue; // cross-port → skip
|
||||
if (!Number.isFinite(a.price) || a.price < 0) continue;
|
||||
await tx
|
||||
.update(berths)
|
||||
.set({ price: String(a.price), priceCurrency: a.currency, updatedAt: new Date() })
|
||||
.where(and(eq(berths.id, a.berthId), eq(berths.portId, portId)));
|
||||
const vid = ownedVid.get(a.berthId);
|
||||
if (vid) {
|
||||
const [ver] = await tx
|
||||
.select({ pr: berthPdfVersions.parseResults })
|
||||
.from(berthPdfVersions)
|
||||
.where(eq(berthPdfVersions.id, vid));
|
||||
const prior = (ver?.pr as Record<string, unknown> | null) ?? {};
|
||||
await tx
|
||||
.update(berthPdfVersions)
|
||||
.set({
|
||||
parseResults: {
|
||||
...prior,
|
||||
bulkPriceApplied: {
|
||||
price: a.price,
|
||||
currency: a.currency,
|
||||
by: actingUserId,
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
.where(eq(berthPdfVersions.id, vid));
|
||||
}
|
||||
updated += 1;
|
||||
}
|
||||
});
|
||||
return { updated };
|
||||
}
|
||||
205
src/lib/services/client-groups.service.ts
Normal file
205
src/lib/services/client-groups.service.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* CM-1: client groups (mailing/segment lists) service.
|
||||
*
|
||||
* CRUD for `client_groups` + membership management on `client_group_members`,
|
||||
* plus a member viewer that resolves each client's primary email for the
|
||||
* copy-emails feature. All reads/writes are port-scoped. Membership replace is
|
||||
* a wipe-and-rewrite transaction (same shape as setEntityTags).
|
||||
*/
|
||||
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
|
||||
import { db } from '@/lib/db';
|
||||
import { clientGroupMembers, clientGroups, clients } from '@/lib/db/schema';
|
||||
import { withTransaction } from '@/lib/db/utils';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { syncGroupToMailchimp } from '@/lib/services/mailchimp.service';
|
||||
import type {
|
||||
CreateClientGroupInput,
|
||||
UpdateClientGroupInput,
|
||||
} from '@/lib/validators/client-groups';
|
||||
|
||||
export interface ClientGroupWithCount {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
mailchimpTag: string | null;
|
||||
memberCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
clientId: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
async function assertGroup(id: string, portId: string) {
|
||||
const group = await db.query.clientGroups.findFirst({
|
||||
where: and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)),
|
||||
});
|
||||
if (!group || group.archivedAt) throw new NotFoundError('Client group not found');
|
||||
return group;
|
||||
}
|
||||
|
||||
export async function listClientGroups(portId: string): Promise<ClientGroupWithCount[]> {
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(clientGroups)
|
||||
.where(and(eq(clientGroups.portId, portId), sql`${clientGroups.archivedAt} IS NULL`))
|
||||
.orderBy(desc(clientGroups.createdAt));
|
||||
|
||||
// Member counts in one grouped query (port-scoped).
|
||||
const counts = await db
|
||||
.select({ groupId: clientGroupMembers.groupId, n: sql<number>`count(*)::int` })
|
||||
.from(clientGroupMembers)
|
||||
.where(eq(clientGroupMembers.portId, portId))
|
||||
.groupBy(clientGroupMembers.groupId);
|
||||
const countMap = new Map(counts.map((c) => [c.groupId, c.n]));
|
||||
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
color: g.color,
|
||||
mailchimpTag: g.mailchimpTag,
|
||||
memberCount: countMap.get(g.id) ?? 0,
|
||||
createdAt: g.createdAt,
|
||||
updatedAt: g.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getClientGroupById(id: string, portId: string) {
|
||||
return assertGroup(id, portId);
|
||||
}
|
||||
|
||||
export async function createClientGroup(
|
||||
portId: string,
|
||||
data: CreateClientGroupInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const [group] = await db
|
||||
.insert(clientGroups)
|
||||
.values({
|
||||
portId,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
color: data.color ?? '#6B7280',
|
||||
mailchimpTag: data.mailchimpTag ?? null,
|
||||
})
|
||||
.returning();
|
||||
if (!group) throw new ValidationError('Failed to create client group');
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'create',
|
||||
entityType: 'client_group',
|
||||
entityId: group.id,
|
||||
newValue: toAuditJson(group),
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
export async function updateClientGroup(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdateClientGroupInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
await assertGroup(id, portId);
|
||||
const [updated] = await db
|
||||
.update(clientGroups)
|
||||
.set({
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.color !== undefined ? { color: data.color } : {}),
|
||||
...(data.mailchimpTag !== undefined ? { mailchimpTag: data.mailchimpTag } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Client group not found');
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'update',
|
||||
entityType: 'client_group',
|
||||
entityId: id,
|
||||
newValue: toAuditJson(data),
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function archiveClientGroup(id: string, portId: string, meta: AuditMeta) {
|
||||
await assertGroup(id, portId);
|
||||
await db
|
||||
.update(clientGroups)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)));
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'archive',
|
||||
entityType: 'client_group',
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
|
||||
/** Members of a group, each with their primary email (for copy-emails). */
|
||||
export async function listGroupMembers(groupId: string, portId: string): Promise<GroupMember[]> {
|
||||
await assertGroup(groupId, portId);
|
||||
const rows = await db
|
||||
.select({
|
||||
clientId: clients.id,
|
||||
fullName: clients.fullName,
|
||||
email: sql<string | null>`(
|
||||
SELECT cc.value FROM client_contacts cc
|
||||
WHERE cc.client_id = ${clients.id} AND cc.channel = 'email'
|
||||
ORDER BY cc.is_primary DESC
|
||||
LIMIT 1
|
||||
)`,
|
||||
})
|
||||
.from(clientGroupMembers)
|
||||
.innerJoin(clients, eq(clientGroupMembers.clientId, clients.id))
|
||||
.where(and(eq(clientGroupMembers.groupId, groupId), eq(clientGroupMembers.portId, portId)))
|
||||
.orderBy(clients.fullName);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Replace a group's membership with exactly `clientIds` (wipe-and-rewrite). */
|
||||
export async function setGroupMembers(
|
||||
groupId: string,
|
||||
portId: string,
|
||||
clientIds: string[],
|
||||
meta: AuditMeta,
|
||||
): Promise<void> {
|
||||
await assertGroup(groupId, portId);
|
||||
const unique = Array.from(new Set(clientIds));
|
||||
// Tenant-scope guard: every client must belong to this port.
|
||||
if (unique.length > 0) {
|
||||
const valid = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(and(inArray(clients.id, unique), eq(clients.portId, portId)));
|
||||
if (valid.length !== unique.length) {
|
||||
throw new ValidationError('One or more clients are not in this port');
|
||||
}
|
||||
}
|
||||
await withTransaction(async (tx) => {
|
||||
await tx.delete(clientGroupMembers).where(eq(clientGroupMembers.groupId, groupId));
|
||||
if (unique.length > 0) {
|
||||
await tx
|
||||
.insert(clientGroupMembers)
|
||||
.values(unique.map((clientId) => ({ groupId, clientId, portId })));
|
||||
}
|
||||
});
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'update',
|
||||
entityType: 'client_group_members',
|
||||
entityId: groupId,
|
||||
newValue: toAuditJson({ clientIds: unique }),
|
||||
});
|
||||
// CM-1 Mailchimp: fire-and-forget one-way push (inert until configured).
|
||||
void syncGroupToMailchimp(groupId, portId).catch(() => {});
|
||||
}
|
||||
@@ -303,6 +303,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const year = String(now.getFullYear());
|
||||
|
||||
// CM-2 Part B: deal-specific price override. The base berth price is the
|
||||
// canonical list price; an interest_berths.price_override (when set)
|
||||
// supersedes it for THIS interest's documents via the existing
|
||||
// {{berth.price}} / {{berth.priceCurrency}} tokens.
|
||||
let resolvedBerthPrice = berth?.price ?? null;
|
||||
let resolvedBerthCurrency = berth?.priceCurrency ?? port.defaultCurrency;
|
||||
if (berth && primaryBerthId) {
|
||||
const [ibOverride] = await db
|
||||
.select({
|
||||
priceOverride: interestBerths.priceOverride,
|
||||
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.where(
|
||||
and(eq(interestBerths.interestId, interest.id), eq(interestBerths.berthId, primaryBerthId)),
|
||||
);
|
||||
if (ibOverride?.priceOverride != null) {
|
||||
resolvedBerthPrice = ibOverride.priceOverride;
|
||||
resolvedBerthCurrency = ibOverride.priceOverrideCurrency ?? berth.priceCurrency;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
client: {
|
||||
id: client.id,
|
||||
@@ -337,8 +359,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
mooringNumber: berth.mooringNumber,
|
||||
area: berth.area,
|
||||
lengthFt: berth.lengthFt,
|
||||
price: berth.price,
|
||||
priceCurrency: berth.priceCurrency,
|
||||
price: resolvedBerthPrice,
|
||||
priceCurrency: resolvedBerthCurrency,
|
||||
tenureType: berth.tenureType,
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
|
||||
addedBy: interestBerths.addedBy,
|
||||
addedAt: interestBerths.addedAt,
|
||||
notes: interestBerths.notes,
|
||||
priceOverride: interestBerths.priceOverride,
|
||||
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
status: berths.status,
|
||||
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
|
||||
.delete(interestBerths)
|
||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
||||
}
|
||||
|
||||
// ─── Per-interest price override (CM-2 Part B) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the effective price for a berth in the context of an interest. The
|
||||
* deal-specific override (when set) supersedes the berth's canonical list
|
||||
* price; the override carries its own currency, falling back to the base
|
||||
* currency when null. Pure — safe to unit-test without a DB.
|
||||
*/
|
||||
export function resolveBerthPriceForInterest(
|
||||
override: { priceOverride: string | null; priceOverrideCurrency: string | null },
|
||||
base: { price: string | null; priceCurrency: string },
|
||||
): { price: string | null; currency: string } {
|
||||
if (override.priceOverride != null) {
|
||||
return {
|
||||
price: override.priceOverride,
|
||||
currency: override.priceOverrideCurrency ?? base.priceCurrency,
|
||||
};
|
||||
}
|
||||
return { price: base.price, currency: base.priceCurrency };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, when `price` is null) the deal-specific price for one
|
||||
* (interest, berth). Tenant-scoped: the interest must belong to `portId`.
|
||||
* Does not touch `berths.price`.
|
||||
*/
|
||||
export async function setBerthPriceOverride(
|
||||
interestId: string,
|
||||
berthId: string,
|
||||
price: number | null,
|
||||
currency: string | null,
|
||||
portId: string,
|
||||
): Promise<void> {
|
||||
const interestRow = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
});
|
||||
if (!interestRow) throw new NotFoundError('Interest');
|
||||
await db
|
||||
.update(interestBerths)
|
||||
.set({
|
||||
priceOverride: price == null ? null : String(price),
|
||||
priceOverrideCurrency: price == null ? null : (currency ?? 'USD'),
|
||||
})
|
||||
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
||||
}
|
||||
|
||||
67
src/lib/services/mailchimp.service.ts
Normal file
67
src/lib/services/mailchimp.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* CM-1: Mailchimp Marketing API integration (one-way push, CRM → Mailchimp).
|
||||
*
|
||||
* SCOPE NOTE: per the locked CM-1 decision, the exact group → tag/segment
|
||||
* mapping is finalised only once we have the client's actual Mailchimp account.
|
||||
* So this module ships the config plumbing + an inert sync that no-ops until
|
||||
* (a) an admin stores an API key + audience ID and (b) the mapping is wired.
|
||||
* The members viewer + copy-emails features do NOT depend on Mailchimp.
|
||||
*
|
||||
* Settings keys (per-port, in system_settings):
|
||||
* - `mailchimp_api_key` (AES-encrypted at rest, like SMTP/IMAP creds)
|
||||
* - `mailchimp_audience_id` (the single audience all groups map into)
|
||||
*/
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { decrypt } from '@/lib/utils/encryption';
|
||||
|
||||
export interface MailchimpConfig {
|
||||
apiKey: string;
|
||||
audienceId: string;
|
||||
/** Datacenter prefix derived from the key suffix (e.g. `us21`). */
|
||||
serverPrefix: string;
|
||||
}
|
||||
|
||||
/** Resolve + decrypt the per-port Mailchimp config, or null when unset. */
|
||||
export async function getMailchimpConfig(portId: string): Promise<MailchimpConfig | null> {
|
||||
const keyRow = await getSetting('mailchimp_api_key', portId);
|
||||
const audRow = await getSetting('mailchimp_audience_id', portId);
|
||||
const encKey = typeof keyRow?.value === 'string' ? keyRow.value : null;
|
||||
const audienceId = typeof audRow?.value === 'string' ? audRow.value : null;
|
||||
if (!encKey || !audienceId) return null;
|
||||
let apiKey: string;
|
||||
try {
|
||||
apiKey = decrypt(encKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Mailchimp keys are `<hex>-<dc>`; the datacenter is the API host prefix.
|
||||
const serverPrefix = apiKey.split('-')[1] ?? '';
|
||||
if (!serverPrefix) return null;
|
||||
return { apiKey, audienceId, serverPrefix };
|
||||
}
|
||||
|
||||
export async function isMailchimpConfigured(portId: string): Promise<boolean> {
|
||||
return (await getMailchimpConfig(portId)) !== null;
|
||||
}
|
||||
|
||||
export type MailchimpSyncResult = { skipped: string } | { synced: true; count: number };
|
||||
|
||||
/**
|
||||
* Push a group's members to Mailchimp as a tag/segment on the port's audience.
|
||||
* Inert until configured AND the mapping is confirmed (see SCOPE NOTE).
|
||||
*/
|
||||
export async function syncGroupToMailchimp(
|
||||
groupId: string,
|
||||
portId: string,
|
||||
): Promise<MailchimpSyncResult> {
|
||||
const config = await getMailchimpConfig(portId);
|
||||
if (!config) return { skipped: 'not-configured' };
|
||||
// TODO(CM-1): mapping pending the client's Mailchimp account. Once confirmed,
|
||||
// upsert each member via
|
||||
// PUT https://{serverPrefix}.api.mailchimp.com/3.0/lists/{audienceId}/members/{md5(lowercased-email)}
|
||||
// then apply the group's tag. Only push subscribed/opted-in contacts (GDPR).
|
||||
logger.info({ groupId, portId }, 'Mailchimp sync requested (mapping pending client account)');
|
||||
return { skipped: 'mapping-pending' };
|
||||
}
|
||||
164
src/lib/services/proxies.service.ts
Normal file
164
src/lib/services/proxies.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* CM-9: proxy / point-of-contact service.
|
||||
*
|
||||
* A proxy is a designated contact attached to a client, interest, or yacht
|
||||
* (one per entity). `resolveEffectiveProxy` picks the most specific for an
|
||||
* outbound-comms context via the chain yacht → interest → client. All
|
||||
* operations are port-scoped; the entity is verified to belong to the port.
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, interests, proxies, yachts } from '@/lib/db/schema';
|
||||
import type { Proxy } from '@/lib/db/schema';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import type { ProxyEntityType, SetProxyInput } from '@/lib/validators/proxies';
|
||||
|
||||
const norm = (v?: string | null): string | null => {
|
||||
const t = v?.trim();
|
||||
return t ? t : null;
|
||||
};
|
||||
|
||||
async function assertEntityInPort(
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
portId: string,
|
||||
): Promise<void> {
|
||||
let exists = false;
|
||||
if (entityType === 'client') {
|
||||
const [r] = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.id, entityId), eq(clients.portId, portId)))
|
||||
.limit(1);
|
||||
exists = !!r;
|
||||
} else if (entityType === 'interest') {
|
||||
const [r] = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.id, entityId), eq(interests.portId, portId)))
|
||||
.limit(1);
|
||||
exists = !!r;
|
||||
} else {
|
||||
const [r] = await db
|
||||
.select({ id: yachts.id })
|
||||
.from(yachts)
|
||||
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
|
||||
.limit(1);
|
||||
exists = !!r;
|
||||
}
|
||||
if (!exists) throw new NotFoundError(`${entityType} not found in this port`);
|
||||
}
|
||||
|
||||
export async function getProxy(
|
||||
portId: string,
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
): Promise<Proxy | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(proxies)
|
||||
.where(
|
||||
and(
|
||||
eq(proxies.portId, portId),
|
||||
eq(proxies.entityType, entityType),
|
||||
eq(proxies.entityId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function setProxy(
|
||||
portId: string,
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
data: SetProxyInput,
|
||||
meta: AuditMeta,
|
||||
): Promise<Proxy> {
|
||||
await assertEntityInPort(entityType, entityId, portId);
|
||||
const next = {
|
||||
name: data.name.trim(),
|
||||
email: norm(data.email),
|
||||
phone: norm(data.phone),
|
||||
relationship: norm(data.relationship),
|
||||
notes: norm(data.notes),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const [row] = await db
|
||||
.insert(proxies)
|
||||
.values({ portId, entityType, entityId, ...next })
|
||||
.onConflictDoUpdate({
|
||||
target: [proxies.portId, proxies.entityType, proxies.entityId],
|
||||
set: next,
|
||||
})
|
||||
.returning();
|
||||
if (!row) throw new NotFoundError('Failed to save proxy');
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'update',
|
||||
entityType: `proxy_${entityType}`,
|
||||
entityId,
|
||||
newValue: toAuditJson(next),
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function clearProxy(
|
||||
portId: string,
|
||||
entityType: ProxyEntityType,
|
||||
entityId: string,
|
||||
meta: AuditMeta,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.delete(proxies)
|
||||
.where(
|
||||
and(
|
||||
eq(proxies.portId, portId),
|
||||
eq(proxies.entityType, entityType),
|
||||
eq(proxies.entityId, entityId),
|
||||
),
|
||||
);
|
||||
void createAuditLog({
|
||||
...meta,
|
||||
action: 'delete',
|
||||
entityType: `proxy_${entityType}`,
|
||||
entityId,
|
||||
});
|
||||
}
|
||||
|
||||
export interface EffectiveProxy {
|
||||
proxy: Proxy;
|
||||
/** Which level the proxy was resolved from. */
|
||||
source: ProxyEntityType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most specific proxy for an outbound-comms context.
|
||||
* Precedence: yacht override → interest override → client default.
|
||||
* Returns null when no proxy is set anywhere in the chain (caller falls back
|
||||
* to the client themselves).
|
||||
*/
|
||||
export async function resolveEffectiveProxy(args: {
|
||||
portId: string;
|
||||
clientId?: string | null;
|
||||
interestId?: string | null;
|
||||
yachtId?: string | null;
|
||||
}): Promise<EffectiveProxy | null> {
|
||||
const { portId, clientId, interestId, yachtId } = args;
|
||||
if (yachtId) {
|
||||
const p = await getProxy(portId, 'yacht', yachtId);
|
||||
if (p) return { proxy: p, source: 'yacht' };
|
||||
}
|
||||
if (interestId) {
|
||||
const p = await getProxy(portId, 'interest', interestId);
|
||||
if (p) return { proxy: p, source: 'interest' };
|
||||
}
|
||||
if (clientId) {
|
||||
const p = await getProxy(portId, 'client', clientId);
|
||||
if (p) return { proxy: p, source: 'client' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
25
src/lib/validators/client-groups.ts
Normal file
25
src/lib/validators/client-groups.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** CM-1: client groups (mailing/segment lists). */
|
||||
|
||||
export const createClientGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Group name is required').max(120),
|
||||
description: z.string().trim().max(2000).nullish(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex value like #6B7280')
|
||||
.optional(),
|
||||
/** Mailchimp tag/segment name this group maps to. Null until wired up. */
|
||||
mailchimpTag: z.string().trim().max(200).nullish(),
|
||||
});
|
||||
|
||||
export const updateClientGroupSchema = createClientGroupSchema.partial();
|
||||
|
||||
/** Wipe-and-rewrite the group's membership to exactly this set of clients. */
|
||||
export const setGroupMembersSchema = z.object({
|
||||
clientIds: z.array(z.string().min(1)).max(5000),
|
||||
});
|
||||
|
||||
export type CreateClientGroupInput = z.infer<typeof createClientGroupSchema>;
|
||||
export type UpdateClientGroupInput = z.infer<typeof updateClientGroupSchema>;
|
||||
export type SetGroupMembersInput = z.infer<typeof setGroupMembersSchema>;
|
||||
17
src/lib/validators/proxies.ts
Normal file
17
src/lib/validators/proxies.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** CM-9: proxy / point-of-contact. */
|
||||
|
||||
export const PROXY_ENTITY_TYPES = ['client', 'interest', 'yacht'] as const;
|
||||
export type ProxyEntityType = (typeof PROXY_ENTITY_TYPES)[number];
|
||||
|
||||
export const setProxySchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(200),
|
||||
// Loose contact fields — empty strings are normalised to null in the service.
|
||||
email: z.string().trim().max(320).nullish(),
|
||||
phone: z.string().trim().max(50).nullish(),
|
||||
relationship: z.string().trim().max(100).nullish(),
|
||||
notes: z.string().trim().max(2000).nullish(),
|
||||
});
|
||||
|
||||
export type SetProxyInput = z.infer<typeof setProxySchema>;
|
||||
@@ -385,6 +385,7 @@ export function makeFullPermissions(): RolePermissions {
|
||||
change_stage: true,
|
||||
},
|
||||
inquiries: { view: true, manage: true },
|
||||
client_groups: { view: true, manage: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -474,6 +475,7 @@ export function makeViewerPermissions(): RolePermissions {
|
||||
change_stage: false,
|
||||
},
|
||||
inquiries: { view: true, manage: false },
|
||||
client_groups: { view: true, manage: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -563,6 +565,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
||||
change_stage: false,
|
||||
},
|
||||
inquiries: { view: true, manage: true },
|
||||
client_groups: { view: true, manage: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -652,6 +655,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
||||
change_stage: true,
|
||||
},
|
||||
inquiries: { view: true, manage: true },
|
||||
client_groups: { view: true, manage: true },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
129
tests/integration/berth-price-reconcile.test.ts
Normal file
129
tests/integration/berth-price-reconcile.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Integration tests for the bulk berth price-reconcile service (CM-2 Part A).
|
||||
*
|
||||
* Uses the real filesystem storage backend (seeded below) + a real spec-sheet
|
||||
* PDF, so the full upload → store → re-parse → extract path is exercised end to
|
||||
* end with no storage mock.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
listPriceReconciliation,
|
||||
applyBulkBerthPrices,
|
||||
} from '@/lib/services/berth-price-reconcile.service';
|
||||
import { uploadBerthPdf } from '@/lib/services/berth-pdf.service';
|
||||
import { db } from '@/lib/db';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
|
||||
import { makeBerth, makeFullPermissions, makePort } from '../helpers/factories';
|
||||
import { makeMockCtx, makeMockRequest } from '../helpers/route-tester';
|
||||
|
||||
const A1_PDF = readFileSync(path.join(process.cwd(), 'berth_pdf_example/Berth_Spec_Sheet_A1.pdf'));
|
||||
|
||||
beforeEach(async () => {
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({ key: 'storage_backend', value: 'filesystem', portId: null, updatedBy: null })
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
|
||||
describe('listPriceReconciliation', () => {
|
||||
it('parses the main price for a berth with a PDF and flags one without', async () => {
|
||||
const port = await makePort();
|
||||
const withPdf = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } });
|
||||
// No-PDF berth — created for its 'no_pdf' row; the value isn't referenced.
|
||||
await makeBerth({ portId: port.id, overrides: { mooringNumber: 'Z9' } });
|
||||
|
||||
await uploadBerthPdf({
|
||||
berthId: withPdf.id,
|
||||
portId: port.id,
|
||||
buffer: A1_PDF,
|
||||
fileName: 'Berth_Spec_Sheet_A1.pdf',
|
||||
uploadedBy: 'test-user',
|
||||
});
|
||||
|
||||
const rows = await listPriceReconciliation(port.id);
|
||||
const w = rows.find((r) => r.mooringNumber === 'A1');
|
||||
const wo = rows.find((r) => r.mooringNumber === 'Z9');
|
||||
|
||||
expect(w?.parsedPrice).toBe(3880800);
|
||||
expect(w?.parsedCurrency).toBe('USD');
|
||||
expect(w?.currentPrice).toBeNull();
|
||||
expect(w?.status).toBe('changed'); // CRM price null → changed
|
||||
expect(wo?.status).toBe('no_pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyBulkBerthPrices', () => {
|
||||
it('writes only approved, in-port berths and skips cross-port ids', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const berthA = await makeBerth({ portId: portA.id, overrides: { mooringNumber: 'A1' } });
|
||||
const berthB = await makeBerth({ portId: portB.id, overrides: { mooringNumber: 'A1' } });
|
||||
|
||||
const res = await applyBulkBerthPrices(
|
||||
portA.id,
|
||||
[
|
||||
{ berthId: berthA.id, price: 3880800, currency: 'USD' },
|
||||
{ berthId: berthB.id, price: 999, currency: 'USD' }, // foreign port → skipped
|
||||
],
|
||||
'test-user',
|
||||
);
|
||||
|
||||
expect(res.updated).toBe(1);
|
||||
|
||||
const [a] = await db.select().from(berths).where(eq(berths.id, berthA.id));
|
||||
expect(Number(a!.price)).toBe(3880800);
|
||||
expect(a!.priceCurrency).toBe('USD');
|
||||
|
||||
const [b] = await db.select().from(berths).where(eq(berths.id, berthB.id));
|
||||
expect(b!.price).toBeNull(); // untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe('price-reconcile route handlers', () => {
|
||||
it('GET lists rows and POST apply writes the approved price', async () => {
|
||||
const { getHandler } = await import('@/app/api/v1/berths/price-reconcile/handlers');
|
||||
const { postHandler } = await import('@/app/api/v1/berths/price-reconcile/apply/handlers');
|
||||
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'A1' } });
|
||||
await uploadBerthPdf({
|
||||
berthId: berth.id,
|
||||
portId: port.id,
|
||||
buffer: A1_PDF,
|
||||
fileName: 'Berth_Spec_Sheet_A1.pdf',
|
||||
uploadedBy: 'test-user',
|
||||
});
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
const listRes = await getHandler(
|
||||
makeMockRequest('GET', 'http://t/api/v1/berths/price-reconcile'),
|
||||
ctx,
|
||||
{},
|
||||
);
|
||||
const listJson = (await listRes.json()) as {
|
||||
data: Array<{ mooringNumber: string; parsedPrice: number | null }>;
|
||||
};
|
||||
expect(listJson.data.find((r) => r.mooringNumber === 'A1')?.parsedPrice).toBe(3880800);
|
||||
|
||||
const applyRes = await postHandler(
|
||||
makeMockRequest('POST', 'http://t/api/v1/berths/price-reconcile/apply', {
|
||||
body: { approvals: [{ berthId: berth.id, price: 3880800, currency: 'USD' }] },
|
||||
}),
|
||||
ctx,
|
||||
{},
|
||||
);
|
||||
const applyJson = (await applyRes.json()) as { data: { updated: number } };
|
||||
expect(applyJson.data.updated).toBe(1);
|
||||
|
||||
const [b] = await db.select().from(berths).where(eq(berths.id, berth.id));
|
||||
expect(Number(b!.price)).toBe(3880800);
|
||||
});
|
||||
});
|
||||
81
tests/integration/client-groups.test.ts
Normal file
81
tests/integration/client-groups.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* CM-1: client-groups service — CRUD, wipe-and-rewrite membership, member
|
||||
* email resolution (for copy-emails), and the port-scope guard.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientContacts } from '@/lib/db/schema';
|
||||
import {
|
||||
archiveClientGroup,
|
||||
createClientGroup,
|
||||
getClientGroupById,
|
||||
listClientGroups,
|
||||
listGroupMembers,
|
||||
setGroupMembers,
|
||||
updateClientGroup,
|
||||
} from '@/lib/services/client-groups.service';
|
||||
import { makeAuditMeta, makeClient, makePort } from '../helpers/factories';
|
||||
|
||||
describe('client-groups.service (CM-1)', () => {
|
||||
it('creates a group and lists it with a zero member count', async () => {
|
||||
const port = await makePort();
|
||||
const meta = makeAuditMeta({ portId: port.id });
|
||||
const group = await createClientGroup(port.id, { name: 'VIP Mailing' }, meta);
|
||||
expect(group.name).toBe('VIP Mailing');
|
||||
expect(group.color).toBe('#6B7280');
|
||||
|
||||
const list = await listClientGroups(port.id);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]?.memberCount).toBe(0);
|
||||
});
|
||||
|
||||
it('sets members (wipe-and-rewrite) and lists them with primary email', async () => {
|
||||
const port = await makePort();
|
||||
const meta = makeAuditMeta({ portId: port.id });
|
||||
const c1 = await makeClient({ portId: port.id });
|
||||
const c2 = await makeClient({ portId: port.id });
|
||||
await db
|
||||
.insert(clientContacts)
|
||||
.values({ clientId: c1.id, channel: 'email', value: 'vip@example.com', isPrimary: true });
|
||||
|
||||
const group = await createClientGroup(port.id, { name: 'Newsletter' }, meta);
|
||||
await setGroupMembers(group.id, port.id, [c1.id, c2.id], meta);
|
||||
|
||||
const members = await listGroupMembers(group.id, port.id);
|
||||
expect(members.map((m) => m.clientId).sort()).toEqual([c1.id, c2.id].sort());
|
||||
expect(members.find((m) => m.clientId === c1.id)?.email).toBe('vip@example.com');
|
||||
expect(members.find((m) => m.clientId === c2.id)?.email).toBeNull();
|
||||
|
||||
const list = await listClientGroups(port.id);
|
||||
expect(list.find((g) => g.id === group.id)?.memberCount).toBe(2);
|
||||
|
||||
// Wipe-and-rewrite: setting to [c2] drops c1.
|
||||
await setGroupMembers(group.id, port.id, [c2.id], meta);
|
||||
const after = await listGroupMembers(group.id, port.id);
|
||||
expect(after.map((m) => m.clientId)).toEqual([c2.id]);
|
||||
});
|
||||
|
||||
it('rejects members from a foreign port', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const meta = makeAuditMeta({ portId: portA.id });
|
||||
const foreign = await makeClient({ portId: portB.id });
|
||||
const group = await createClientGroup(portA.id, { name: 'Scoped' }, meta);
|
||||
await expect(setGroupMembers(group.id, portA.id, [foreign.id], meta)).rejects.toThrow(
|
||||
/not in this port/,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates and archives a group', async () => {
|
||||
const port = await makePort();
|
||||
const meta = makeAuditMeta({ portId: port.id });
|
||||
const group = await createClientGroup(port.id, { name: 'Temp' }, meta);
|
||||
const updated = await updateClientGroup(group.id, port.id, { name: 'Renamed' }, meta);
|
||||
expect(updated.name).toBe('Renamed');
|
||||
|
||||
await archiveClientGroup(group.id, port.id, meta);
|
||||
await expect(getClientGroupById(group.id, port.id)).rejects.toThrow(/not found/i);
|
||||
expect(await listClientGroups(port.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
94
tests/integration/proxies.test.ts
Normal file
94
tests/integration/proxies.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* CM-9: proxy service — per-entity CRUD/upsert, tenant guard, and the
|
||||
* yacht → interest → client resolution precedence.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { createInterest } from '@/lib/services/interests.service';
|
||||
import {
|
||||
clearProxy,
|
||||
getProxy,
|
||||
resolveEffectiveProxy,
|
||||
setProxy,
|
||||
} from '@/lib/services/proxies.service';
|
||||
import { makeAuditMeta, makeClient, makePort, makeYacht } from '../helpers/factories';
|
||||
|
||||
describe('proxies.service (CM-9)', () => {
|
||||
it('sets, reads, upserts and clears a client proxy', async () => {
|
||||
const port = await makePort();
|
||||
const meta = makeAuditMeta({ portId: port.id });
|
||||
const client = await makeClient({ portId: port.id });
|
||||
|
||||
expect(await getProxy(port.id, 'client', client.id)).toBeNull();
|
||||
|
||||
const p = await setProxy(
|
||||
port.id,
|
||||
'client',
|
||||
client.id,
|
||||
{ name: 'Broker Bob', email: 'bob@example.com' },
|
||||
meta,
|
||||
);
|
||||
expect(p.name).toBe('Broker Bob');
|
||||
expect(p.email).toBe('bob@example.com');
|
||||
|
||||
// Upsert: one proxy per entity — setting again updates the same row.
|
||||
const p2 = await setProxy(
|
||||
port.id,
|
||||
'client',
|
||||
client.id,
|
||||
{ name: 'Broker Bob', email: '', phone: '+100' },
|
||||
meta,
|
||||
);
|
||||
expect(p2.id).toBe(p.id);
|
||||
expect(p2.email).toBeNull(); // empty string normalised to null
|
||||
expect(p2.phone).toBe('+100');
|
||||
|
||||
await clearProxy(port.id, 'client', client.id, meta);
|
||||
expect(await getProxy(port.id, 'client', client.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an entity from a foreign port', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const meta = makeAuditMeta({ portId: portA.id });
|
||||
const foreign = await makeClient({ portId: portB.id });
|
||||
await expect(setProxy(portA.id, 'client', foreign.id, { name: 'X' }, meta)).rejects.toThrow(
|
||||
/not found in this port/,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the most specific proxy: yacht → interest → client', async () => {
|
||||
const port = await makePort();
|
||||
const meta = makeAuditMeta({ portId: port.id });
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const interest = await createInterest(
|
||||
port.id,
|
||||
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
|
||||
meta,
|
||||
);
|
||||
const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id });
|
||||
|
||||
await setProxy(port.id, 'client', client.id, { name: 'Client PoC' }, meta);
|
||||
await setProxy(port.id, 'interest', interest.id, { name: 'Deal PoC' }, meta);
|
||||
await setProxy(port.id, 'yacht', yacht.id, { name: 'Vessel PoC' }, meta);
|
||||
|
||||
const ctx = {
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
interestId: interest.id,
|
||||
yachtId: yacht.id,
|
||||
};
|
||||
expect((await resolveEffectiveProxy(ctx))?.source).toBe('yacht');
|
||||
|
||||
await clearProxy(port.id, 'yacht', yacht.id, meta);
|
||||
expect((await resolveEffectiveProxy(ctx))?.source).toBe('interest');
|
||||
|
||||
await clearProxy(port.id, 'interest', interest.id, meta);
|
||||
const eff = await resolveEffectiveProxy(ctx);
|
||||
expect(eff?.source).toBe('client');
|
||||
expect(eff?.proxy.name).toBe('Client PoC');
|
||||
|
||||
await clearProxy(port.id, 'client', client.id, meta);
|
||||
expect(await resolveEffectiveProxy(ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,11 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractFromOcrText,
|
||||
extractPurchasePrice,
|
||||
isPdfMagic,
|
||||
parseFeetInches,
|
||||
parseHumanDate,
|
||||
PURCHASE_PRICE_FLOOR,
|
||||
shouldOfferAiTier,
|
||||
} from '@/lib/services/berth-pdf-parser';
|
||||
|
||||
@@ -191,3 +193,43 @@ describe('shouldOfferAiTier', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPurchasePrice', () => {
|
||||
it('isolates the single clean main price among letter-spaced rate garble', () => {
|
||||
// Real-sheet shape: rates are letter-spaced (so they never match the strict
|
||||
// token); the main price renders clean.
|
||||
const text =
|
||||
'W E E K H I G H / LO W : 1 1 , 3 4 1 U S D / 8 , 1 0 0 U S D 3,880,800 USD ' +
|
||||
'DAY H I G H / LO W : 1 , 8 9 0 U S D / 1 , 3 5 0 U S D';
|
||||
const r = extractPurchasePrice(text);
|
||||
expect(r.value).toBe(3880800);
|
||||
expect(r.currency).toBe('USD');
|
||||
expect(r.confidence).toBeGreaterThanOrEqual(0.9);
|
||||
});
|
||||
|
||||
it('excludes clean rate tokens below the floor (synthetic clean sheet)', () => {
|
||||
const text = '3,880,800 USD WEEK HIGH / LOW: 11,341 USD / 8,100 USD';
|
||||
expect(extractPurchasePrice(text).value).toBe(3880800);
|
||||
});
|
||||
|
||||
it('returns null + warning when no price-magnitude token is present', () => {
|
||||
const r = extractPurchasePrice('no prices here, just 12 USD of nothing');
|
||||
expect(r.value).toBeNull();
|
||||
expect(r.warning).toMatch(/no purchase-price/i);
|
||||
});
|
||||
|
||||
it('flags ambiguity when two DISTINCT above-floor tokens appear', () => {
|
||||
const r = extractPurchasePrice('3,880,800 USD and also 1,247,400 USD');
|
||||
expect(r.value).toBeNull();
|
||||
expect(r.warning).toMatch(/multiple/i);
|
||||
});
|
||||
|
||||
it('treats a repeated identical price as unambiguous', () => {
|
||||
const r = extractPurchasePrice('720,720 USD ... header ... 720,720 USD');
|
||||
expect(r.value).toBe(720720);
|
||||
});
|
||||
|
||||
it('exposes the floor constant', () => {
|
||||
expect(PURCHASE_PRICE_FLOOR).toBe(50_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { buildEoiContext } from '@/lib/services/eoi-context';
|
||||
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
|
||||
import { makePort, makeClient, makeCompany, makeBerth, makeYacht } from '../../helpers/factories';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
@@ -338,6 +339,32 @@ describe('buildEoiContext', () => {
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client address/i);
|
||||
});
|
||||
|
||||
it('renders the deal-specific override price for the primary berth', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||
const berth = await makeBerth({
|
||||
portId: port.id,
|
||||
overrides: { mooringNumber: 'P-1', price: '3880800', priceCurrency: 'USD' },
|
||||
});
|
||||
const interest = await insertInterest({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
berthId: berth.id,
|
||||
});
|
||||
|
||||
// Base list price flows through first.
|
||||
let ctx = await buildEoiContext(interest.id, port.id);
|
||||
expect(ctx.berth?.price).toBe('3880800');
|
||||
expect(ctx.berth?.priceCurrency).toBe('USD');
|
||||
|
||||
// The deal-specific override supersedes the list price for this interest.
|
||||
await setBerthPriceOverride(interest.id, berth.id, 1500000, 'EUR', port.id);
|
||||
ctx = await buildEoiContext(interest.id, port.id);
|
||||
expect(ctx.berth?.price).toBe('1500000');
|
||||
expect(ctx.berth?.priceCurrency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('throws NotFoundError for non-existent interest', async () => {
|
||||
const port = await makePort();
|
||||
await expect(buildEoiContext('fake-id', port.id)).rejects.toThrow(NotFoundError);
|
||||
|
||||
32
tests/unit/services/interest-berths-price.test.ts
Normal file
32
tests/unit/services/interest-berths-price.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { resolveBerthPriceForInterest } from '@/lib/services/interest-berths.service';
|
||||
|
||||
describe('resolveBerthPriceForInterest', () => {
|
||||
it('uses the override when present', () => {
|
||||
expect(
|
||||
resolveBerthPriceForInterest(
|
||||
{ priceOverride: '1000000', priceOverrideCurrency: 'EUR' },
|
||||
{ price: '3880800', priceCurrency: 'USD' },
|
||||
),
|
||||
).toEqual({ price: '1000000', currency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the base list price when no override', () => {
|
||||
expect(
|
||||
resolveBerthPriceForInterest(
|
||||
{ priceOverride: null, priceOverrideCurrency: null },
|
||||
{ price: '3880800', priceCurrency: 'USD' },
|
||||
),
|
||||
).toEqual({ price: '3880800', currency: 'USD' });
|
||||
});
|
||||
|
||||
it('uses the base currency when the override currency is null', () => {
|
||||
expect(
|
||||
resolveBerthPriceForInterest(
|
||||
{ priceOverride: '900000', priceOverrideCurrency: null },
|
||||
{ price: '3880800', priceCurrency: 'USD' },
|
||||
),
|
||||
).toEqual({ price: '900000', currency: 'USD' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user