15 Commits

Author SHA1 Message Date
5b9560531e fix(ui): remove PN brand mark from mobile topbar; balance title with spacer
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m59s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:55:18 +02:00
f55be14813 test(berths): CM-2 — drop unused var in price-reconcile test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:53:24 +02:00
6bc81270b9 feat(interests): CM-2 Part B — deal-price override route + UI on linked berths
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:48:38 +02:00
38e392e38b feat(interests): CM-2 Part B — EOI/doc generation honours berth price override
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:41:42 +02:00
039ef25fe5 feat(interests): CM-2 Part B — interest_berths price override (data + resolver)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:17 +02:00
b3753b96a1 feat(berths): CM-2 — bulk price-reconcile admin page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:38:08 +02:00
9147f2857e feat(berths): CM-2 — price-reconcile API (list + bulk apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:35:09 +02:00
47778796ad feat(berths): CM-2 — bulk price-reconcile service (parse + apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:33:40 +02:00
f7425d1231 fix(berths): CM-2 — robust purchase-price extraction (clean-token + magnitude floor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:30:12 +02:00
df8c26d1b3 feat(proxies): CM-9 UI — ProxyCard on client, interest, and yacht detail pages
- shared ProxyCard (view/add/edit/remove point-of-contact) reading each entity's
  /[id]/proxy sub-resource; permission-gated on the entity's edit right
- wired into the client overview, interest overview, and yacht overview tabs

Completes CM-9. tsc clean, lint 0 errors, prod build green, 1638 vitest pass.
Comms send-side wiring (route EOIs/emails through resolveEffectiveProxy) is a
deliberate follow-up — the resolver + data are ready for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:01:08 +02:00
91703bdb00 feat(proxies): CM-9 backend — polymorphic point-of-contact + resolver
- proxies table (migration 0095, port_id cascade), one per client/interest/yacht
- service: get/set(upsert)/clear + resolveEffectiveProxy (yacht → interest →
  client precedence), port-scoped with entity-in-port guard
- per-entity sub-resource routes (/clients|interests|yachts/[id]/proxy) reusing
  each entity's existing view/edit permission (no new permission resource)
- 3 integration tests (CRUD/upsert, tenant guard, resolution precedence)

Backend only — ProxyCard UI on the 3 detail pages to follow. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:54:47 +02:00
3165ec651f feat(client-groups): CM-1 API routes + UI (list, member viewer, copy-emails)
- /api/v1/client-groups (list/create), /[id] (get/patch/delete),
  /[id]/members (get/set) — route.ts + handlers.ts split, client_groups perms
- Client Groups list page (grid + create dialog) and detail page
  (member viewer, per-row copy email, "Copy all emails" → To:-bar format,
  manage-members picker over /api/v1/clients)
- Sidebar nav entry (UsersRound icon)

tsc clean, lint 0 errors, prod build green. Completes CM-1 (Mailchimp push
still deferred until client creds/account).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:29 +02:00
661187cc79 feat(client-groups): CM-1 data layer — groups entity, membership, service, Mailchimp scaffold
- client_groups + client_group_members tables (migration 0094, port_id cascade)
- client_groups permission resource (view/manage) in catalog + role backfill
- service: CRUD + wipe-and-rewrite membership + member email resolution
- mailchimp.service scaffold: config reader + inert one-way sync (mapping
  deferred until the client's MC account is wired, per CM-1 decision)
- 4 integration tests (CRUD, membership, email resolution, port-scope guard)

Backend only — API routes + UI to follow. tsc clean, 1635 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:28:20 +02:00
4dc0bdd8c4 feat(crm): client-meeting batch — contact-pill cleanup, assignment toggle, receipt manual mode
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 9m16s
CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest
  detail headers; relocate GDPR export into the client-header action cluster
  as a compact icon. Keeps the interest "Log contact" quick action.
CM-5: gate the interest assignment feature behind a per-port `assignment_enabled`
  setting (default OFF for single-rep ports). Hides the AssignedToChip +
  residential assigned-to row and skips tier-2/3 auto-assign on create; the
  column + data are preserved and reversible. Tests cover the auto-assign guard.
CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form).
  Threaded through ocr-config.service, the admin OCR form, the scan-receipt
  route, and the scanner shell (skips Tesseract + the server call). Tests cover
  the save/resolve round-trip.

Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:42:36 +02:00
7f04c765f4 fix(crm): inquiry detail polish, EOI preview mime, EOI next-step, documenso v1 banner
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 7m43s
- inquiries: format triage badges with labels (Open/Assigned/Converted/Dismissed),
  surface the lead's free-text message for every kind, and gate the raw-payload
  tab to super admins (was exposing raw JSON to all users)
- file preview: fall back to the server-resolved mime (getPreviewUrl already
  returns it) so files whose stored name lacks a .pdf extension — e.g.
  migration-backfilled signed EOIs — render instead of "preview not supported"
- interest overview: a signed EOI left at stage=eoi no longer shows as
  "NEXT STEP"; completion ordering rolls the next step to Reservation (display
  only, no pipeline_stage change)
- documenso admin: warning banner discouraging the deprecated v1 API + what
  breaks on it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:35 +02:00
74 changed files with 3181 additions and 228 deletions

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Route } from 'next'; 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 { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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.", "Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch, icon: FileSearch,
}, },
{
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
label: 'Price reconciliation',
description:
'Parse the purchase price from each berths 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; ] as const;
return ( return (

View File

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

View File

@@ -7,6 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card'; import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { WarningCallout } from '@/components/ui/warning-callout';
// All field arrays removed - every Documenso setting now flows through // All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global // `RegistryDrivenForm`, which surfaces the env-fallback / port / global
@@ -22,6 +23,35 @@ export default function DocumensoSettingsPage() {
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it." description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
/> />
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
<p>
The CRM&apos;s signing features are built for Documenso 2.x (v2). Set the API version
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
</p>
<ul className="ms-4 mt-1 list-disc space-y-1">
<li>
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
</li>
<li>
<strong>Upload-and-send contracts / reservations</strong> fall back to v1&apos;s
per-field placement: page size is assumed to be A4, and rich field metadata (required
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
</li>
<li>
<strong>One-call send with per-recipient signing links</strong>,{' '}
<strong>sequential signing enforcement</strong>, and the{' '}
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
are unavailable or ignored on v1.
</li>
</ul>
<p className="mt-1">
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
the test-connection button to confirm.
</p>
</WarningCallout>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">

View File

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

View File

@@ -0,0 +1,5 @@
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
export default function ClientGroupsPage() {
return <ClientGroupsList />;
}

View File

@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config'; import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Scan receipt', title: 'Scan receipt',
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
const { portSlug } = await params; const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) }); const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null; const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />; // CM-6: manual-entry mode is resolved server-side so the client can skip
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
return (
<ScanShell
logoUrl={branding?.logoUrl ?? null}
portName={port?.name ?? null}
manualEntry={ocr?.manualEntry ?? false}
/>
);
} }

View File

@@ -15,6 +15,7 @@ const saveSchema = z.object({
clearApiKey: z.boolean().optional(), clearApiKey: z.boolean().optional(),
useGlobal: z.boolean().optional(), useGlobal: z.boolean().optional(),
aiEnabled: z.boolean().optional(), aiEnabled: z.boolean().optional(),
manualEntry: z.boolean().optional(),
}); });
// Only role tiers that hold `admin.manage_settings` (director / super_admin) // Only role tiers that hold `admin.manage_settings` (director / super_admin)
@@ -58,6 +59,7 @@ export const PUT = withAuth(
clearApiKey: body.clearApiKey, clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal, useGlobal: body.useGlobal,
aiEnabled: body.aiEnabled, aiEnabled: body.aiEnabled,
manualEntry: body.manualEntry,
}, },
ctx.userId, ctx.userId,
); );

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

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { postHandler } from './handlers';
export const POST = withAuth(withPermission('berths', 'edit', postHandler));

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

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler } from './handlers';
export const GET = withAuth(withPermission('berths', 'edit', getHandler));

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

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

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

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

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

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

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

View File

@@ -48,6 +48,14 @@ export const POST = withAuth(
} }
const config = await getResolvedOcrConfig(ctx.portId); const config = await getResolvedOcrConfig(ctx.portId);
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
// types the details by hand. The client should skip this route entirely
// in manual mode, but we guard server-side too.
if (config.manualEntry) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
});
}
// Tesseract.js (in-browser) is the default. The server only invokes // Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on // an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its // and (b) a key resolves. Otherwise the client falls back to its

View File

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

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { putHandler } from './handlers';
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));

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

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

View File

@@ -30,6 +30,7 @@ interface ConfigResp {
hasApiKey: boolean; hasApiKey: boolean;
useGlobal: boolean; useGlobal: boolean;
aiEnabled: boolean; aiEnabled: boolean;
manualEntry: boolean;
}; };
models: Record<Provider, string[]>; models: Record<Provider, string[]>;
} }
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
// Key the body on the loaded payload so useState initializers seed // Key the body on the loaded payload so useState initializers seed
// from server values cleanly. // from server values cleanly.
const sig = data?.data const sig = data?.data
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}` ? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
: 'loading'; : 'loading';
return ( return (
<SettingsBlockBody <SettingsBlockBody
@@ -89,6 +90,7 @@ function SettingsBlockBody({
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false); const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false); const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>( const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null, null,
); );
@@ -105,6 +107,7 @@ function SettingsBlockBody({
clearApiKey: Boolean(clearApiKey), clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal, useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled, aiEnabled: scope === 'global' ? false : aiEnabled,
manualEntry: scope === 'global' ? false : manualEntry,
}, },
}), }),
onSuccess: () => { onSuccess: () => {
@@ -190,6 +193,25 @@ function SettingsBlockBody({
</div> </div>
) : null} ) : null}
{scope === 'port' ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`manualEntry-${scope}`}
checked={manualEntry}
onCheckedChange={(v) => setManualEntry(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
Manual entry only (skip receipt scanning)
</Label>
<p className="text-xs text-muted-foreground">
When on, staff just attach a receipt photo and type the details by hand - no
on-device or AI parsing runs. Takes precedence over AI parsing above.
</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label> <Label htmlFor={`provider-${scope}`}>Provider</Label>

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, defaultValue: true,
}, },
{
key: 'assignment_enabled',
label: 'Interest Assignment',
description:
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
type: 'boolean',
defaultValue: false,
},
{ {
key: 'tenancies_module_enabled', key: 'tenancies_module_enabled',
label: 'Tenancies Module', label: 'Tenancies Module',

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

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

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

View File

@@ -3,11 +3,9 @@
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import type { Route } from 'next'; import type { Route } from 'next';
import { useState } from 'react'; import { useState } from 'react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react'; import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const primaryEmail = const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.value; client.contacts?.find((c) => c.channel === 'email')?.value;
const primaryPhoneContact =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
const primaryPhone = primaryPhoneContact?.value;
// wa.me requires the E.164 number without the leading "+". Strip from the
// canonical E.164 form when available; otherwise strip non-digits from the
// display value as a best-effort fallback.
const whatsappNumber = primaryPhoneContact?.valueE164
? primaryPhoneContact.valueE164.replace(/^\+/, '')
: primaryPhoneContact?.value
? primaryPhoneContact.value.replace(/[^\d]/g, '')
: null;
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null; const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt const addedLabel = client.createdAt
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</p> </p>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1"> {/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
{primaryEmail ? ( request. GDPR export moved to the top-right action cluster.
<Button Portal-invite stays as the one primary CTA here. */}
asChild {!isArchived && client.clientPortalEnabled === true ? (
variant="outline" <div className="flex flex-wrap items-center gap-1.5 pt-1">
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
<Mail />
Email
</a>
</Button>
) : null}
{primaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{!isArchived && client.clientPortalEnabled === true ? (
<div className="hidden sm:inline-flex"> <div className="hidden sm:inline-flex">
<PortalInviteButton <PortalInviteButton
clientId={client.id} clientId={client.id}
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail} defaultEmail={primaryEmail}
/> />
</div> </div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div> </div>
</div> ) : null}
{client.tags && client.tags.length > 0 && ( {client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
right perm) permanently-delete. Destructive actions sit out right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */} of the primary action flow. */}
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
{/* CM-4: GDPR export relocated here as a compact icon trigger,
alongside reminder/archive/delete. Self-gates on permission. */}
<GdprExportButton clientId={client.id} variant="icon" />
{isArchived && ( {isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients"> <PermissionGate resource="admin" action="permanently_delete_clients">
<button <button

View File

@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab'; import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary'; import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
@@ -156,6 +157,9 @@ function OverviewTab({
<ClientPipelineSummary clientId={clientId} variant="panel" /> <ClientPipelineSummary clientId={clientId} variant="panel" />
</div> </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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */} {/* Personal Info */}
<div className="space-y-1"> <div className="space-y-1">

View File

@@ -48,7 +48,15 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
failed: 'destructive', failed: 'destructive',
}; };
export function GdprExportButton({ clientId }: { clientId: string }) { export function GdprExportButton({
clientId,
variant = 'button',
}: {
clientId: string;
/** `button` = standalone outline button (default). `icon` = compact icon-only
* trigger for the detail-header top-right action cluster (CM-4). */
variant?: 'button' | 'icon';
}) {
const { can, isSuperAdmin } = usePermissions(); const { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8"> {variant === 'icon' ? (
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden /> <button
GDPR export type="button"
</Button> aria-label="GDPR export"
title="GDPR export"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<FileDown className="size-4" aria-hidden />
</button>
) : (
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export
</Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>

View File

@@ -104,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The // useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set. // request is gated on the dialog being open and a fileId being set.
const previewQuery = useQuery<{ data: { url: string } }>({ const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
queryKey: ['file-preview', fileId], queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`), queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId, enabled: open && !!fileId,
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading; const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null; const error = previewQuery.error ? 'Failed to load preview' : null;
const kind = previewKindFor(mimeType, fileName); // Prefer the caller-supplied mime, but fall back to the server's resolved
// mime (getPreviewUrl returns it). Without this, callers that pass only a
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
// through to the "unknown" surface even though the server knows it's a PDF.
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
const kind = previewKindFor(resolvedMime, fileName);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -49,6 +49,13 @@ export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
dismissed: 'bg-slate-100 text-slate-600', dismissed: 'bg-slate-100 text-slate-600',
}; };
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
open: 'Open',
assigned: 'Assigned',
converted: 'Converted',
dismissed: 'Dismissed',
};
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [ export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'contactEmail', label: 'Email' }, { id: 'contactEmail', label: 'Email' },
{ id: 'kind', label: 'Type' }, { id: 'kind', label: 'Type' },
@@ -114,7 +121,7 @@ export function getInquiryColumns({
const state = row.original.triageState; const state = row.original.triageState;
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Badge className={TRIAGE_TONE[state]}>{state}</Badge> <Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
{row.original.convertedInterestId ? ( {row.original.convertedInterestId ? (
<Link <Link
href={`/${portSlug}/interests/${row.original.convertedInterestId}`} href={`/${portSlug}/interests/${row.original.convertedInterestId}`}

View File

@@ -8,8 +8,10 @@ import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout'
import { DetailNotFound } from '@/components/shared/detail-not-found'; import { DetailNotFound } from '@/components/shared/detail-not-found';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import { import {
KIND_LABELS, KIND_LABELS,
TRIAGE_LABELS,
TRIAGE_TONE, TRIAGE_TONE,
type InquiryKind, type InquiryKind,
type InquiryTriageState, type InquiryTriageState,
@@ -49,6 +51,7 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export function InquiryDetail({ id }: { id: string }) { export function InquiryDetail({ id }: { id: string }) {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const { isSuperAdmin } = usePermissions();
const { data, isLoading, error } = useQuery<InquiryDetailData>({ const { data, isLoading, error } = useQuery<InquiryDetailData>({
queryKey: ['inquiries', id], queryKey: ['inquiries', id],
@@ -74,6 +77,10 @@ export function InquiryDetail({ id }: { id: string }) {
const p = (data?.payload ?? {}) as Record<string, unknown>; const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : ''); const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
// The free-text message a lead left. Website forms use different keys
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
// common ones and surface it for every inquiry kind.
const comment = str('comments') || str('message') || str('comment') || str('notes');
const tabs: DetailTab[] = [ const tabs: DetailTab[] = [
{ {
@@ -88,7 +95,9 @@ export function InquiryDetail({ id }: { id: string }) {
<Row label="Place of residence" value={str('address')} /> <Row label="Place of residence" value={str('address')} />
) : null} ) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null} {data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{data?.kind === 'contact_form' ? <Row label="Comments" value={str('comments')} /> : null} {comment ? (
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
) : null}
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} /> <Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} /> <Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
<Row label="Source IP" value={data?.sourceIp} /> <Row label="Source IP" value={data?.sourceIp} />
@@ -107,7 +116,9 @@ export function InquiryDetail({ id }: { id: string }) {
label="Status" label="Status"
value={ value={
data ? ( data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge> <Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : ( ) : (
'' ''
) )
@@ -155,7 +166,7 @@ export function InquiryDetail({ id }: { id: string }) {
</pre> </pre>
), ),
}, },
]; ].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
return ( return (
<DetailLayout <DetailLayout
@@ -166,7 +177,9 @@ export function InquiryDetail({ id }: { id: string }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1> <h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
{data ? ( {data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge> <Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : null} ) : null}
</div> </div>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">

View File

@@ -11,14 +11,11 @@ import {
Trophy, Trophy,
XCircle, XCircle,
RefreshCcw, RefreshCcw,
Mail,
MessageSquarePlus, MessageSquarePlus,
Phone,
AlarmClock, AlarmClock,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab'; import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip';
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip'; import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip'; import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { formatOutcome } from '@/lib/constants'; import { formatOutcome } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label'; import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
id: string; id: string;
clientId: string; clientId: string;
clientName: string | null; clientName: string | null;
/** Primary contact channels resolved from the linked client. The header /** Primary contact channels resolved from the linked client. The
* uses these to render Email / Call / WhatsApp buttons so the rep * Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
* doesn't have to navigate to the client page just to reach out. */ * for downstream reuse (e.g. proxy comms routing, CM-9). */
clientPrimaryEmail?: string | null; clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null; clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null; clientPrimaryPhoneE164?: string | null;
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [logContactOpen, setLogContactOpen] = useState(false); const [logContactOpen, setLogContactOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false); const [reminderOpen, setReminderOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.) // (Upload-paper-signed-EOI dialog moved to the EOI tab.)
// CM-5: assignment UI is hidden when the per-port toggle is off (default).
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const isArchived = !!interest.archivedAt; const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome); const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome; const isClosed = !!interest.outcome;
// Contact deep-links - resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
const whatsappNumber = interest.clientPrimaryPhoneE164
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
: interest.clientPrimaryPhone
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
: null;
const reopenMutation = useMutation({ const reopenMutation = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }), apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount} {interest.activeReminderCount}
</span> </span>
) : null} ) : null}
<PermissionGate resource="interests" action="edit"> {assignmentEnabled ? (
<AssignedToChip <PermissionGate resource="interests" action="edit">
interestId={interest.id} <AssignedToChip
currentAssignedTo={interest.assignedTo ?? null} interestId={interest.id}
currentAssignedToName={interest.assignedToName ?? null} currentAssignedTo={interest.assignedTo ?? null}
/> currentAssignedToName={interest.assignedToName ?? null}
</PermissionGate> />
</PermissionGate>
) : null}
<MultiEoiChip interestId={interest.id} /> <MultiEoiChip interestId={interest.id} />
<DealPulseChip <DealPulseChip
interest={{ interest={{
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div> </div>
)} )}
{/* Contact deep-links - let the rep email / call / WhatsApp the {/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
client without leaving the interest workspace. Resolved from Client-page link + Log-contact action stay - the rep can still
the linked client's primary contact channels (server-side jump to the client and record outreach without leaving here. */}
fetch in getInterestById). */} <div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientPrimaryEmail || {interest.clientId ? (
interest.clientPrimaryPhone ||
whatsappNumber ||
interest.clientId ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
) : null}
{interest.clientPrimaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`mailto:${interest.clientPrimaryEmail}`}
aria-label={`Email ${interest.clientPrimaryEmail}`}
>
<Mail />
Email
</a>
</Button>
) : null}
{interest.clientPrimaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`tel:${interest.clientPrimaryPhone}`}
aria-label={`Call ${interest.clientPrimaryPhone}`}
>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
<Button <Button
asChild
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5" className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
> >
<MessageSquarePlus /> <Link
Log contact // eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button> </Button>
</div> ) : null}
) : null} <Button
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
>
<MessageSquarePlus />
Log contact
</Button>
</div>
</div> </div>
{/* Top-right actions. Won/Lost are sales-critical and read as text {/* Top-right actions. Won/Lost are sales-critical and read as text

View File

@@ -19,6 +19,7 @@ import {
AccordionTrigger, AccordionTrigger,
} from '@/components/ui/accordion'; } from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history'; import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor'; import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
@@ -848,7 +849,18 @@ function OverviewTab({
deposit_paid: 'deposit', deposit_paid: 'deposit',
contract: 'contract', contract: 'contract',
}; };
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null; const stageOwnedMilestoneRaw =
STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
// B2 (2026-06-18): if the stage-owned milestone is already COMPLETE — e.g. a
// migrated deal left at stage=eoi with a signed EOI that never auto-advanced —
// don't pin it as the current "NEXT STEP". Falling back to null makes phaseFor
// use completion ordering, so the signed milestone shows as done/past and the
// next incomplete one (Reservation) becomes current. Display-only; the
// pipeline_stage column is unchanged.
const stageOwnedMilestoneComplete = stageOwnedMilestoneRaw
? milestoneCompletion[stageOwnedMilestoneRaw]
: false;
const stageOwnedMilestone = stageOwnedMilestoneComplete ? null : stageOwnedMilestoneRaw;
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1; const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
const phaseFor = (k: (typeof order)[number]): Phase => { const phaseFor = (k: (typeof order)[number]): Phase => {
// Stage owns this milestone → always current, never collapsed. // Stage owns this milestone → always current, never collapsed.
@@ -1122,6 +1134,9 @@ function OverviewTab({
archivedAt={null} 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 {/* Qualification checklist - surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */} of 'enquiry'. Hidden when the port has no enabled criteria. */}

View File

@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
addedBy: string | null; addedBy: string | null;
addedAt: string; addedAt: string;
notes: string | null; notes: string | null;
priceOverride: string | null;
priceOverrideCurrency: string | null;
mooringNumber: string | null; mooringNumber: string | null;
area: string | null; area: string | null;
status: string; 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 ────────────────────────────────────────────────────────── // ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps { interface BypassDialogProps {
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
}: RowProps) { }: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false); const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = 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 dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed'; 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 ( return (
<div <div
className={cn( className={cn(
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
</div> </div>
</TooltipProvider> </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&apos;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 ? ( {showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description // Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't // on the left, action button inline with the description so it doesn't

View File

@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
/** /**
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium: * Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
* deep-navy gradient surface with white type, the brand "PN" mark on the * deep-navy gradient surface with white type, a back arrow on the left when
* left when there's no back affordance, and a soft glow shadow underneath * there's a back affordance (otherwise a balancing spacer), and a soft glow
* for depth instead of a hard divider line. * shadow underneath for depth instead of a hard divider line.
* *
* Slots: title (auto-truncating), back arrow, primary action - all driven by * Slots: title (auto-truncating), back arrow, primary action - all driven by
* `useMobileChrome()` from the active page. When no page has set a title the * `useMobileChrome()` from the active page. When no page has set a title the
@@ -47,17 +47,6 @@ export function MobileTopbar() {
portTitle || portTitle ||
'CRM'; '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 ( return (
<header <header
className={cn( className={cn(
@@ -71,15 +60,10 @@ export function MobileTopbar() {
{backTarget ? ( {backTarget ? (
<BackButton variant="mobile" /> <BackButton variant="mobile" />
) : ( ) : (
<div // No back affordance on top-level pages. Render an empty spacer the
aria-label={portTitle || 'Home'} // same width as the right-hand action slot so the centered title
className={cn( // stays optically centered (the brand "PN" mark was removed here).
'size-9 shrink-0 rounded-lg flex items-center justify-center', <div className="size-11 shrink-0" aria-hidden />
'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>
)} )}
<h1 <h1

View File

@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
UsersRound,
Bookmark, Bookmark,
Anchor, Anchor,
KeyRound, KeyRound,
@@ -113,6 +114,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
items: [ items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard }, { href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users }, { 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}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 }, { href: `${base}/companies`, label: 'Companies', icon: Building2 },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark }, { href: `${base}/interests`, label: 'Interests', icon: Bookmark },

View File

@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { SOURCES } from '@/lib/constants'; import { SOURCES } from '@/lib/constants';
interface ResidentialInterest { interface ResidentialInterest {
@@ -95,6 +96,8 @@ function OverviewTab({
stageOptions: Array<{ value: string; label: string }>; stageOptions: Array<{ value: string; label: string }>;
}) { }) {
const update = useInterestPatch(interestId); const update = useInterestPatch(interestId);
// CM-5: residential assignment row hidden when the per-port toggle is off.
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const save = (field: string) => async (next: string | null) => { const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next }); await update.mutateAsync({ [field]: next });
}; };
@@ -105,6 +108,7 @@ function OverviewTab({
}>({ }>({
queryKey: ['residential-assignable-users'], queryKey: ['residential-assignable-users'],
queryFn: () => apiFetch('/api/v1/residential/assignable-users'), queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
enabled: assignmentEnabled,
}); });
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({ const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
value: u.id, value: u.id,
@@ -132,15 +136,17 @@ function OverviewTab({
onSave={save('source')} onSave={save('source')}
/> />
</Row> </Row>
<Row label="Assigned to"> {assignmentEnabled ? (
<InlineEditableField <Row label="Assigned to">
variant="select" <InlineEditableField
options={assigneeOptions} variant="select"
value={interest.assignedTo} options={assigneeOptions}
onSave={save('assignedTo')} value={interest.assignedTo}
placeholder="Unassigned" onSave={save('assignedTo')}
/> placeholder="Unassigned"
</Row> />
</Row>
) : null}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">

View File

@@ -320,9 +320,11 @@ interface ScanShellProps {
* imagery. */ * imagery. */
logoUrl?: string | null; logoUrl?: string | null;
portName?: string | null; portName?: string | null;
/** CM-6: when true, skip ALL parsing - open an empty form for manual entry. */
manualEntry?: boolean;
} }
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) { export function ScanShell({ logoUrl, portName, manualEntry = false }: ScanShellProps = {}) {
const router = useRouter(); const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug); const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
if (imagePreview) URL.revokeObjectURL(imagePreview); if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file)); setImagePreview(URL.createObjectURL(file));
setCurrentFile(file); setCurrentFile(file);
// CM-6: manual-entry mode - the port admin disabled scanning. Skip
// Tesseract AND the server call entirely; go straight to an empty form.
if (manualEntry) {
setState({
kind: 'verify',
parsed: {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
},
source: 'manual',
reason: 'manual-mode',
});
return;
}
setState({ kind: 'processing', engine: 'tesseract' }); setState({ kind: 'processing', engine: 'tesseract' });
// Always run Tesseract first - it's free, on-device, and gives us a // Always run Tesseract first - it's free, on-device, and gives us a

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

View File

@@ -12,6 +12,7 @@ import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialo
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history'; import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { ProxyCard } from '@/components/shared/proxy-card';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list'; import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
@@ -176,6 +177,10 @@ function OverviewTab({
return ( return (
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}> <FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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 */} {/* Identity */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3> <h3 className="text-sm font-medium mb-2">Identity</h3>

View 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 };
}

View File

@@ -70,6 +70,7 @@ export const PERMISSION_CATALOG = {
residential_clients: ['view', 'create', 'edit', 'delete'], residential_clients: ['view', 'create', 'edit', 'delete'],
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'], residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
inquiries: ['view', 'manage'], inquiries: ['view', 'manage'],
client_groups: ['view', 'manage'],
} as const satisfies { } as const satisfies {
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>; [R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
}; };

View 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');

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

View File

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

View 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;

View File

@@ -7,6 +7,12 @@ export * from './users';
// Clients // Clients
export * from './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 // Companies
export * from './companies'; export * from './companies';

View File

@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
addedBy: text('added_by'), addedBy: text('added_by'),
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(), addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
notes: text('notes'), 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) => [ (table) => [
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId), uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),

View 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;

View File

@@ -166,6 +166,10 @@ export type RolePermissions = {
view: boolean; view: boolean;
manage: boolean; manage: boolean;
}; };
client_groups: {
view: boolean;
manage: boolean;
};
}; };
/** /**

View File

@@ -92,6 +92,10 @@ export const ALL_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: true, manage: true,
}, },
client_groups: {
view: true,
manage: true,
},
}; };
export const DIRECTOR_PERMISSIONS: RolePermissions = { export const DIRECTOR_PERMISSIONS: RolePermissions = {
@@ -175,6 +179,10 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: true, manage: true,
}, },
client_groups: {
view: true,
manage: true,
},
}; };
export const SALES_MANAGER_PERMISSIONS: RolePermissions = { export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
@@ -258,6 +266,10 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: true, manage: true,
}, },
client_groups: {
view: true,
manage: true,
},
}; };
export const SALES_AGENT_PERMISSIONS: RolePermissions = { export const SALES_AGENT_PERMISSIONS: RolePermissions = {
@@ -341,6 +353,10 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: true, manage: true,
}, },
client_groups: {
view: true,
manage: true,
},
}; };
export const VIEWER_PERMISSIONS: RolePermissions = { export const VIEWER_PERMISSIONS: RolePermissions = {
@@ -430,6 +446,10 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
view: true, view: true,
manage: false, manage: false,
}, },
client_groups: {
view: true,
manage: false,
},
}; };
// Residential Partner - for an outside party who handles residential // Residential Partner - for an outside party who handles residential
@@ -522,4 +542,8 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
view: false, view: false,
manage: false, manage: false,
}, },
client_groups: {
view: false,
manage: false,
},
}; };

View File

@@ -357,10 +357,14 @@ export function extractFromOcrText(rawText: string): {
}; };
} }
// Purchase price: "PURCHASE PRICE:\nFEE SIMPLE OR STRATA LOT\n3,880,800 USD" // Purchase price: the single clean comma-grouped currency figure. The rates
const priceMatch = text.match(/PURCHASE\s+PRICE[\s\S]{0,80}?([0-9][0-9,]+)\s*USD/i); // on the same sheet are letter-spaced (garble) and below the floor, so they
if (priceMatch) { // never collide with the main price. See extractPurchasePrice().
out.price = { value: Number(priceMatch[1]!.replace(/,/g, '')), confidence: 0.7, engine: 'ocr' }; 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" // 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; return numeric < 0 ? null : numeric;
} }
/**
* Floor that separates a 67-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". */ /** Parse a human date like "September 15 2025" → "2025-09-15". */
export function parseHumanDate(raw: string): string | null { export function parseHumanDate(raw: string): string | null {
const cleaned = raw.replace(/(\d+)(st|nd|rd|th)/i, '$1').trim(); const cleaned = raw.replace(/(\d+)(st|nd|rd|th)/i, '$1').trim();

View 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 };
}

View 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(() => {});
}

View File

@@ -303,6 +303,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
const today = now.toISOString().slice(0, 10); const today = now.toISOString().slice(0, 10);
const year = String(now.getFullYear()); 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 { return {
client: { client: {
id: client.id, id: client.id,
@@ -337,8 +359,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
mooringNumber: berth.mooringNumber, mooringNumber: berth.mooringNumber,
area: berth.area, area: berth.area,
lengthFt: berth.lengthFt, lengthFt: berth.lengthFt,
price: berth.price, price: resolvedBerthPrice,
priceCurrency: berth.priceCurrency, priceCurrency: resolvedBerthCurrency,
tenureType: berth.tenureType, tenureType: berth.tenureType,
} }
: null, : null,

View File

@@ -170,6 +170,8 @@ export async function listBerthsForInterest(
addedBy: interestBerths.addedBy, addedBy: interestBerths.addedBy,
addedAt: interestBerths.addedAt, addedAt: interestBerths.addedAt,
notes: interestBerths.notes, notes: interestBerths.notes,
priceOverride: interestBerths.priceOverride,
priceOverrideCurrency: interestBerths.priceOverrideCurrency,
mooringNumber: berths.mooringNumber, mooringNumber: berths.mooringNumber,
area: berths.area, area: berths.area,
status: berths.status, status: berths.status,
@@ -444,3 +446,49 @@ export async function removeInterestBerth(
.delete(interestBerths) .delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))); .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)));
}

View File

@@ -833,7 +833,12 @@ export async function createInterest(portId: string, data: CreateInterestInput,
// every new lead. Falls back to null (Unassigned) when none of // every new lead. Falls back to null (Unassigned) when none of
// the above resolve. // the above resolve.
let resolvedAssignedTo = interestData.assignedTo ?? null; let resolvedAssignedTo = interestData.assignedTo ?? null;
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) { // CM-5: tiers 2 & 3 (port default-owner + auto-assign-to-creator) only run
// when the per-port assignment feature is enabled. Tier 1 (an explicit
// assignedTo from the caller) is always honored. Default is OFF.
const assignmentSetting = await getSetting('assignment_enabled', portId);
const assignmentEnabled = assignmentSetting?.value === true;
if (assignmentEnabled && resolvedAssignedTo === null && !('assignedTo' in interestData)) {
const defaultOwner = await getSetting('default_new_interest_owner', portId); const defaultOwner = await getSetting('default_new_interest_owner', portId);
const v = defaultOwner?.value as { userId?: string } | null | undefined; const v = defaultOwner?.value as { userId?: string } | null | undefined;
if (v?.userId) { if (v?.userId) {

View 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' };
}

View File

@@ -37,6 +37,12 @@ export interface OcrConfigPublic {
* provider is never called even if a key is configured. * provider is never called even if a key is configured.
*/ */
aiEnabled: boolean; aiEnabled: boolean;
/**
* CM-6: manual-entry mode. When true the scanner skips ALL parsing
* (Tesseract + AI) and presents an empty form for the operator to fill in
* by hand. Per-port; takes precedence over `aiEnabled`. Default false.
*/
manualEntry: boolean;
} }
/** Internal shape including the decrypted key - server-side only. */ /** Internal shape including the decrypted key - server-side only. */
@@ -52,6 +58,7 @@ interface StoredOcrConfig {
apiKeyEncrypted: string | null; apiKeyEncrypted: string | null;
useGlobal: boolean; useGlobal: boolean;
aiEnabled?: boolean; aiEnabled?: boolean;
manualEntry?: boolean;
} }
const KEY = 'ocr.config'; const KEY = 'ocr.config';
@@ -106,12 +113,14 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: false, hasApiKey: false,
useGlobal: portRow?.useGlobal === true, useGlobal: portRow?.useGlobal === true,
aiEnabled: false, aiEnabled: false,
manualEntry: portRow?.manualEntry === true,
source: 'none', source: 'none',
}; };
} }
// The aiEnabled flag is per-port: even if the port falls back to a global // The aiEnabled / manualEntry flags are per-port: even if the port falls back
// key, the port admin still has to flip the switch on this port. // to a global key, the port admin still has to flip these on this port.
const aiEnabled = portRow?.aiEnabled === true; const aiEnabled = portRow?.aiEnabled === true;
const manualEntry = portRow?.manualEntry === true;
return { return {
provider: sourceRow.provider, provider: sourceRow.provider,
model: sourceRow.model, model: sourceRow.model,
@@ -119,6 +128,7 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: Boolean(sourceRow.apiKeyEncrypted), hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
useGlobal: portRow?.useGlobal === true, useGlobal: portRow?.useGlobal === true,
aiEnabled, aiEnabled,
manualEntry,
source: useGlobal ? 'global' : 'port', source: useGlobal ? 'global' : 'port',
}; };
} }
@@ -133,6 +143,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: false, hasApiKey: false,
useGlobal: false, useGlobal: false,
aiEnabled: false, aiEnabled: false,
manualEntry: false,
}; };
} }
return { return {
@@ -141,6 +152,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: Boolean(row.apiKeyEncrypted), hasApiKey: Boolean(row.apiKeyEncrypted),
useGlobal: row.useGlobal, useGlobal: row.useGlobal,
aiEnabled: row.aiEnabled === true, aiEnabled: row.aiEnabled === true,
manualEntry: row.manualEntry === true,
}; };
} }
@@ -154,6 +166,8 @@ export interface SaveOcrConfigInput {
useGlobal?: boolean; useGlobal?: boolean;
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */ /** Per-port toggle: enable AI receipt parsing. Defaults to false. */
aiEnabled?: boolean; aiEnabled?: boolean;
/** Per-port toggle: manual entry (skip all parsing). Defaults to false. */
manualEntry?: boolean;
} }
export async function saveOcrConfig( export async function saveOcrConfig(
@@ -171,6 +185,9 @@ export async function saveOcrConfig(
// AI is meaningful only at the port scope. Preserve the existing flag if the // AI is meaningful only at the port scope. Preserve the existing flag if the
// caller didn't pass one (so toggling provider/model doesn't re-disable AI). // caller didn't pass one (so toggling provider/model doesn't re-disable AI).
const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false); const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false);
// Manual entry is also port-only; preserve when the caller omits it.
const manualEntry =
portId === null ? false : (input.manualEntry ?? existing?.manualEntry ?? false);
await writeRow( await writeRow(
portId, portId,
{ {
@@ -179,6 +196,7 @@ export async function saveOcrConfig(
apiKeyEncrypted, apiKeyEncrypted,
useGlobal: portId === null ? false : Boolean(input.useGlobal), useGlobal: portId === null ? false : Boolean(input.useGlobal),
aiEnabled, aiEnabled,
manualEntry,
}, },
userId, userId,
); );

View 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;
}

View 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>;

View 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>;

View File

@@ -385,6 +385,7 @@ export function makeFullPermissions(): RolePermissions {
change_stage: true, change_stage: true,
}, },
inquiries: { view: true, manage: true }, inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
}; };
} }
@@ -474,6 +475,7 @@ export function makeViewerPermissions(): RolePermissions {
change_stage: false, change_stage: false,
}, },
inquiries: { view: true, manage: false }, inquiries: { view: true, manage: false },
client_groups: { view: true, manage: false },
}; };
} }
@@ -563,6 +565,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
change_stage: false, change_stage: false,
}, },
inquiries: { view: true, manage: true }, inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
}; };
} }
@@ -652,6 +655,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
change_stage: true, change_stage: true,
}, },
inquiries: { view: true, manage: true }, inquiries: { view: true, manage: true },
client_groups: { view: true, manage: true },
}; };
} }

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

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

View File

@@ -0,0 +1,94 @@
/**
* CM-5: interest assignment is gated behind the per-port `assignment_enabled`
* setting. When off (the default), createInterest must NOT auto-assign an owner
* even when a `default_new_interest_owner` is configured. When on, the existing
* tier-2 (port default-owner) auto-assign fires. An explicit `assignedTo` from
* the caller (tier 1) is always honored regardless of the toggle.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
// interests.assigned_to FKs to user_profiles(user_id); the owner must exist.
const OWNER = 'cm5-default-owner';
describe('interests.service - assignment_enabled gate (CM-5)', () => {
let createInterest: typeof import('@/lib/services/interests.service').createInterest;
let makePort: typeof import('../helpers/factories').makePort;
let makeClient: typeof import('../helpers/factories').makeClient;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
beforeAll(async () => {
const svc = await import('@/lib/services/interests.service');
createInterest = svc.createInterest;
const factories = await import('../helpers/factories');
makePort = factories.makePort;
makeClient = factories.makeClient;
makeAuditMeta = factories.makeAuditMeta;
// Idempotent owner profile - left in place (created interests reference it,
// so we never delete it in teardown).
await db
.insert(userProfiles)
.values({ userId: OWNER, displayName: 'CM5 Default Owner' })
.onConflictDoNothing();
});
afterEach(async () => {
await db.delete(systemSettings).where(eq(systemSettings.key, 'assignment_enabled'));
await db.delete(systemSettings).where(eq(systemSettings.key, 'default_new_interest_owner'));
});
async function setSetting(portId: string, key: string, value: unknown) {
await db.insert(systemSettings).values({ key, portId, value: value as never });
}
it('does NOT auto-assign the port default owner when assignment is disabled (default)', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// A default owner IS configured, but the feature is OFF - the guard must
// skip tier-2 entirely and leave the interest unassigned.
await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBeNull();
});
it('auto-assigns the port default owner when assignment is enabled', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await setSetting(port.id, 'assignment_enabled', true);
await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBe(OWNER);
});
it('always honors an explicit assignedTo regardless of the toggle', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Feature off, but the caller explicitly picked an owner - tier 1 wins.
const interest = await createInterest(
port.id,
{
clientId: client.id,
assignedTo: OWNER,
pipelineStage: 'enquiry',
tagIds: [],
reminderEnabled: false,
},
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBe(OWNER);
});
});

View File

@@ -147,6 +147,60 @@ describe('OCR config', () => {
expect(resolved.aiEnabled).toBe(false); expect(resolved.aiEnabled).toBe(false);
}); });
// CM-6: manual-entry mode (skip all parsing) - mirrors the aiEnabled contract.
it('manualEntry defaults to false and round-trips when toggled', async () => {
const port = await makePort();
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-y' },
'user-1',
);
let resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(false);
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', manualEntry: true },
'user-1',
);
resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(true);
expect(resolved.apiKey).toBe('sk-y'); // toggling the mode never wipes the key
});
it('manualEntry is preserved when other fields change', async () => {
const port = await makePort();
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-z', manualEntry: true },
'user-1',
);
// Update the model only - manualEntry must survive (mirrors aiEnabled).
await saveOcrConfig(port.id, { provider: 'openai', model: 'gpt-4o' }, 'user-1');
const resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(true);
expect(resolved.model).toBe('gpt-4o');
});
it('manualEntry shows on the public view and is forced false at global scope', async () => {
await saveOcrConfig(
null,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'g', manualEntry: true },
'user-1',
);
const port = await makePort();
const resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(false); // per-port, never inherited from global
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', manualEntry: true },
'user-1',
);
const pub = await getPublicOcrConfig(port.id);
expect(pub.manualEntry).toBe(true);
});
it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => { it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => {
await saveOcrConfig( await saveOcrConfig(
null, null,

View 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();
});
});

View File

@@ -13,9 +13,11 @@ import { describe, expect, it } from 'vitest';
import { import {
extractFromOcrText, extractFromOcrText,
extractPurchasePrice,
isPdfMagic, isPdfMagic,
parseFeetInches, parseFeetInches,
parseHumanDate, parseHumanDate,
PURCHASE_PRICE_FLOOR,
shouldOfferAiTier, shouldOfferAiTier,
} from '@/lib/services/berth-pdf-parser'; } from '@/lib/services/berth-pdf-parser';
@@ -191,3 +193,43 @@ describe('shouldOfferAiTier', () => {
).toBe(false); ).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);
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { buildEoiContext } from '@/lib/services/eoi-context'; 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 { makePort, makeClient, makeCompany, makeBerth, makeYacht } from '../../helpers/factories';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { import {
@@ -338,6 +339,32 @@ describe('buildEoiContext', () => {
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client address/i); 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 () => { it('throws NotFoundError for non-existent interest', async () => {
const port = await makePort(); const port = await makePort();
await expect(buildEoiContext('fake-id', port.id)).rejects.toThrow(NotFoundError); await expect(buildEoiContext('fake-id', port.id)).rejects.toThrow(NotFoundError);

View 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' });
});
});