feat(pipeline): 9→7 stage refactor + v1.1 hardening wave

Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:39:21 +02:00
parent b10bf9bf8e
commit 6b28459c45
110 changed files with 5402 additions and 796 deletions

View File

@@ -1,5 +1,5 @@
import Link from 'next/link';
import { Bot, Receipt, FileText, Brain, ExternalLink } from 'lucide-react';
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
import {
SettingsFormCard,
@@ -7,6 +7,7 @@ import {
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
const MASTER_FIELDS: SettingFieldDef[] = [
{
@@ -59,13 +60,6 @@ interface FeatureLink {
}
const FEATURE_LINKS: FeatureLink[] = [
{
href: '../ocr',
icon: Receipt,
title: 'Receipt OCR settings',
description:
'Provider, model, and confidence thresholds for the receipt scanner. AI fallback only runs when the on-device parser is uncertain.',
},
{
href: '../berth-pdf-parser',
icon: FileText,
@@ -103,6 +97,21 @@ export default function AiAdminPage() {
fields={PROVIDER_FIELDS}
/>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Bot className="h-4 w-4" /> Receipt OCR
</CardTitle>
<CardDescription>
Provider, model, and confidence thresholds for the receipt scanner. AI fallback only
runs when the on-device parser is uncertain.
</CardDescription>
</CardHeader>
<CardContent>
<OcrSettingsForm embedded />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">

View File

@@ -0,0 +1,15 @@
import { QualificationCriteriaAdmin } from '@/components/admin/qualification-criteria-admin';
import { PageHeader } from '@/components/shared/page-header';
export default function QualificationCriteriaPage() {
return (
<div className="space-y-6">
<PageHeader
title="Qualification criteria"
eyebrow="ADMIN"
description="Configure the checklist reps complete before a deal moves out of the Enquiry stage. Reorder, enable/disable, or add port-specific criteria. The 'fully qualified' hint on the interest detail surfaces when every enabled criterion is confirmed."
/>
<QualificationCriteriaAdmin />
</div>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus, Download, FileText, FileSpreadsheet } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
@@ -21,7 +21,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { PermissionGate } from '@/components/shared/permission-gate';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { ExpenseCard } from '@/components/expenses/expense-card';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { buildExpenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -33,6 +33,18 @@ export default function ExpensesPage() {
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
// Per-port category override. Falls back to shipped defaults until the
// vocab call resolves, so the filter bar always renders something.
const { data: vocab } = useQuery<{ data: Record<string, readonly string[]> }>({
queryKey: ['vocabularies'],
queryFn: () => apiFetch('/api/v1/vocabularies'),
staleTime: 5 * 60_000,
});
const filterDefs = useMemo(
() => buildExpenseFilterDefinitions(vocab?.data?.expense_categories),
[vocab],
);
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null);
@@ -53,7 +65,7 @@ export default function ExpensesPage() {
} = usePaginatedQuery<ExpenseRow>({
queryKey: ['expenses'],
endpoint: '/api/v1/expenses',
filterDefinitions: expenseFilterDefinitions,
filterDefinitions: filterDefs,
});
useRealtimeInvalidation({
@@ -132,7 +144,7 @@ export default function ExpensesPage() {
/>
<FilterBar
filters={expenseFilterDefinitions}
filters={filterDefs}
values={filters}
onChange={setFilter}
onClear={clearFilters}

View File

@@ -36,15 +36,13 @@ function portalSigningLabel(status: string): string {
}
const STAGE_VARIANT: Record<PipelineStage, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'secondary',
details_sent: 'secondary',
in_communication: 'default',
eoi_sent: 'default',
eoi_signed: 'default',
deposit_10pct: 'default',
contract_sent: 'default',
contract_signed: 'default',
completed: 'outline',
enquiry: 'secondary',
qualified: 'secondary',
nurturing: 'secondary',
eoi: 'default',
reservation: 'default',
deposit_paid: 'default',
contract: 'outline',
};
export default async function PortalInterestsPage() {

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { updateQualificationCriterionSchema } from '@/lib/validators/qualification';
import { deleteCriterion, updateCriterion } from '@/lib/services/qualification.service';
export const PATCH = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateQualificationCriterionSchema);
const row = await updateCriterion(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
await deleteCriterion(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,33 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createQualificationCriterionSchema } from '@/lib/validators/qualification';
import { createCriterion, listCriteriaForPort } from '@/lib/services/qualification.service';
export const GET = withAuth(async (_req, ctx) => {
try {
const data = await listCriteriaForPort(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
});
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, createQualificationCriterionSchema);
const row = await createCriterion(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: row }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -15,6 +15,8 @@ export const PATCH = withAuth(
channel: body.channel,
direction: body.direction,
summary: body.summary,
voiceTranscript: body.voiceTranscript,
templateUsed: body.templateUsed,
followUpAt: body.followUpAt,
});
return NextResponse.json({ data: entry });

View File

@@ -27,6 +27,8 @@ export const POST = withAuth(
channel: body.channel,
direction: body.direction,
summary: body.summary,
voiceTranscript: body.voiceTranscript ?? null,
templateUsed: body.templateUsed ?? null,
followUpAt: body.followUpAt ?? null,
});
return NextResponse.json({ data: entry }, { status: 201 });

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createPaymentSchema } from '@/lib/validators/payments';
import {
createPayment,
getDepositTotalForInterest,
listPaymentsForInterest,
} from '@/lib/services/payments.service';
export const GET = withAuth(
withPermission('interests', 'view', async (_req, ctx, params) => {
try {
const interestId = params.id!;
const [payments, depositTotal] = await Promise.all([
listPaymentsForInterest(interestId, ctx.portId),
getDepositTotalForInterest(interestId, ctx.portId),
]);
return NextResponse.json({ data: { payments, depositTotal } });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('invoices', 'record_payment', async (req, ctx, params) => {
try {
// Body's interestId must match the URL param — defense-in-depth against
// a client that sends one ID in the URL but another in the body.
const body = await parseBody(req, createPaymentSchema);
if (body.interestId !== params.id) {
return NextResponse.json(
{ error: 'interestId in body must match URL parameter' },
{ status: 400 },
);
}
const payment = await createPayment(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: payment }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setInterestQualificationSchema } from '@/lib/validators/qualification';
import {
isInterestFullyQualified,
listInterestQualifications,
setInterestQualification,
} from '@/lib/services/qualification.service';
export const GET = withAuth(
withPermission('interests', 'view', async (_req, ctx, params) => {
try {
const interestId = params.id!;
const [criteria, fullyQualified] = await Promise.all([
listInterestQualifications(interestId, ctx.portId),
isInterestFullyQualified(interestId, ctx.portId),
]);
return NextResponse.json({ data: { criteria, fullyQualified } });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PUT = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, setInterestQualificationSchema);
const criteria = await setInterestQualification(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
const fullyQualified = criteria.every((c) => c.confirmed);
return NextResponse.json({ data: { criteria, fullyQualified } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { updatePaymentSchema } from '@/lib/validators/payments';
import { deletePayment, updatePayment } from '@/lib/services/payments.service';
export const PATCH = withAuth(
withPermission('invoices', 'record_payment', async (req, ctx, params) => {
try {
const body = await parseBody(req, updatePaymentSchema);
const payment = await updatePayment(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: payment });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('invoices', 'record_payment', async (_req, ctx, params) => {
try {
await deletePayment(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

@@ -3,27 +3,34 @@
import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
Bell,
Activity,
BarChart3,
BellRing,
BookOpen,
Briefcase,
Database,
ClipboardList,
CopyCheck,
DatabaseBackup,
FilePen,
FileSignature,
FileText,
Globe,
HardDrive,
FileUp,
Inbox,
Key,
LayoutDashboard,
ListChecks,
Mail,
Palette,
MailPlus,
Paintbrush,
ScrollText,
Search,
Send,
Server,
Settings,
Shield,
Sliders,
Ship,
SlidersHorizontal,
Sparkles,
Tag,
Upload,
TrendingUp,
Users,
UsersRound,
Webhook,
X,
} from 'lucide-react';
@@ -83,7 +90,7 @@ const GROUPS: AdminGroup[] = [
href: 'invitations',
label: 'Invitations',
description: 'Send invitations, track pending invites, and resend or revoke them.',
icon: Mail,
icon: MailPlus,
},
{
href: 'roles',
@@ -108,19 +115,19 @@ const GROUPS: AdminGroup[] = [
label: 'EOI signing service',
description:
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileText,
icon: FileSignature,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: Bell,
icon: BellRing,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Palette,
icon: Paintbrush,
},
{
href: 'settings',
@@ -183,7 +190,7 @@ const GROUPS: AdminGroup[] = [
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
icon: ClipboardList,
},
{
href: 'templates',
@@ -195,7 +202,7 @@ const GROUPS: AdminGroup[] = [
href: 'email-templates',
label: 'Email Templates',
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
icon: Mail,
icon: FilePen,
},
{
href: 'tags',
@@ -214,7 +221,7 @@ const GROUPS: AdminGroup[] = [
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
icon: SlidersHorizontal,
},
],
},
@@ -233,19 +240,19 @@ const GROUPS: AdminGroup[] = [
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Mail,
icon: Send,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: UsersRound,
icon: CopyCheck,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
icon: FileUp,
},
{
href: 'audit',
@@ -263,26 +270,26 @@ const GROUPS: AdminGroup[] = [
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
icon: BarChart3,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
icon: Activity,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: HardDrive,
icon: DatabaseBackup,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: HardDrive,
icon: Server,
},
],
},
@@ -294,14 +301,14 @@ const GROUPS: AdminGroup[] = [
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
icon: Ship,
},
{
href: 'onboarding',
label: 'Onboarding checklist',
description:
'Step-by-step setup checklist for fresh ports — auto-detects what youve configured and lets you mark manual steps complete.',
icon: LayoutDashboard,
icon: ListChecks,
},
],
},
@@ -313,22 +320,28 @@ const GROUPS: AdminGroup[] = [
href: 'ai',
label: 'AI configuration',
description:
'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).',
icon: ScrollText,
keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'],
},
{
href: 'ocr',
label: 'Receipt OCR (per-feature)',
description: 'Provider, model, and confidence thresholds for the receipt scanner.',
icon: ScrollText,
keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'],
'Master switch, provider credentials, and the Receipt OCR settings in one place. Per-feature pages (berth-PDF parser, recommender) link out from here.',
icon: Sparkles,
keywords: [
'openai',
'anthropic',
'gpt',
'claude',
'llm',
'api key',
'embeddings',
'receipt',
'scan',
'tesseract',
'ocr',
'expense scanner',
],
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
icon: TrendingUp,
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
},
{
@@ -336,9 +349,17 @@ const GROUPS: AdminGroup[] = [
label: 'Residential pipeline stages',
description:
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: ScrollText,
icon: ListChecks,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
{
href: 'qualification-criteria',
label: 'Qualification criteria',
description:
'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the soft "ready to qualify" hint on the interest detail.',
icon: ListChecks,
keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'],
},
],
},
];

View File

@@ -314,17 +314,19 @@ function SettingsBlockBody({
);
}
export function OcrSettingsForm() {
export function OcrSettingsForm({ embedded = false }: { embedded?: boolean } = {}) {
const { isSuperAdmin } = usePermissions();
return (
<div className="space-y-6">
<PageHeader
title="Receipt OCR"
eyebrow="Admin"
description="Receipts are scanned on-device by default. Optionally configure an AI provider for higher-accuracy parsing on tricky receipts."
variant="gradient"
/>
{embedded ? null : (
<PageHeader
title="Receipt OCR"
eyebrow="Admin"
description="Receipts are scanned on-device by default. Optionally configure an AI provider for higher-accuracy parsing on tricky receipts."
variant="gradient"
/>
)}
<SettingsBlock
scope="port"

View File

@@ -0,0 +1,341 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, ChevronUp, ChevronDown, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface CriterionRow {
id: string;
key: string;
label: string;
description: string | null;
enabled: boolean;
displayOrder: number;
}
interface ListResponse {
data: CriterionRow[];
}
/**
* Per-port qualification-criteria admin. Lists current criteria, add via
* the dialog, toggle enabled inline, drag-style reorder via up/down buttons
* (keeps the UI simple for v1; can swap to a real DnD later if reps want it).
*/
export function QualificationCriteriaAdmin() {
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['qualification-criteria'],
queryFn: () => apiFetch('/api/v1/admin/qualification-criteria'),
});
const criteria = data?.data ?? [];
const toggleEnabled = useMutation({
mutationFn: async (vars: { id: string; enabled: boolean }) =>
apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, {
method: 'PATCH',
body: { enabled: vars.enabled },
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
onError: (err) => toastError(err),
});
const reorder = useMutation({
mutationFn: async (vars: { id: string; displayOrder: number }) =>
apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, {
method: 'PATCH',
body: { displayOrder: vars.displayOrder },
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
onError: (err) => toastError(err),
});
const deleteCriterion = useMutation({
mutationFn: async (id: string) =>
apiFetch(`/api/v1/admin/qualification-criteria/${id}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
onError: (err) => toastError(err),
});
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading criteria</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{criteria.length} criteria configured · {criteria.filter((c) => c.enabled).length} enabled
</p>
<Button size="sm" onClick={() => setCreateOpen(true)} className="gap-1.5">
<Plus className="size-4" aria-hidden />
Add criterion
</Button>
</div>
{criteria.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/20 p-8 text-center">
<p className="text-sm font-medium">No criteria configured yet.</p>
<p className="mt-1 text-xs text-muted-foreground">
Add the first criterion the rep needs to confirm before a deal can be qualified.
</p>
</div>
) : (
<ul className="divide-y divide-border rounded-lg border">
{criteria.map((c, idx) => {
const isFirst = idx === 0;
const isLast = idx === criteria.length - 1;
return (
<li key={c.id} className="flex items-start gap-3 p-3">
<div className="flex flex-col gap-0.5">
<button
type="button"
aria-label="Move up"
disabled={isFirst || reorder.isPending}
onClick={() =>
reorder.mutate({ id: c.id, displayOrder: Math.max(0, c.displayOrder - 1) })
}
className={cn(
'rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30',
)}
>
<ChevronUp className="size-3.5" aria-hidden />
</button>
<button
type="button"
aria-label="Move down"
disabled={isLast || reorder.isPending}
onClick={() => reorder.mutate({ id: c.id, displayOrder: c.displayOrder + 1 })}
className={cn(
'rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30',
)}
>
<ChevronDown className="size-3.5" aria-hidden />
</button>
</div>
<CriterionEditableRow
criterion={c}
onToggleEnabled={(enabled) => toggleEnabled.mutate({ id: c.id, enabled })}
/>
<button
type="button"
aria-label="Delete criterion"
disabled={deleteCriterion.isPending}
onClick={() => {
if (
confirm(
`Delete criterion "${c.label}"? Per-interest state rows for this key will become orphaned (hidden from the UI but kept in audit history).`,
)
) {
deleteCriterion.mutate(c.id);
}
}}
className="ml-auto rounded p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" aria-hidden />
</button>
</li>
);
})}
</ul>
)}
<CreateCriterionDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}
function CriterionEditableRow({
criterion,
onToggleEnabled,
}: {
criterion: CriterionRow;
onToggleEnabled: (enabled: boolean) => void;
}) {
const queryClient = useQueryClient();
const [label, setLabel] = useState(criterion.label);
const [description, setDescription] = useState(criterion.description ?? '');
const isDirty =
label.trim() !== criterion.label || (description.trim() || null) !== criterion.description;
const save = useMutation({
mutationFn: async () =>
apiFetch(`/api/v1/admin/qualification-criteria/${criterion.id}`, {
method: 'PATCH',
body: { label: label.trim(), description: description.trim() || null },
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
onError: (err) => toastError(err),
});
return (
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
className="h-7 max-w-md text-sm font-medium"
/>
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{criterion.key}
</code>
<div className="ml-auto flex items-center gap-2">
{isDirty ? (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={save.isPending || label.trim().length === 0}
onClick={() => save.mutate()}
>
<Save className="size-3" aria-hidden />
Save
</Button>
) : null}
<Switch
checked={criterion.enabled}
onCheckedChange={onToggleEnabled}
aria-label={criterion.enabled ? 'Disable' : 'Enable'}
/>
</div>
</div>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Optional helper text shown under the checkbox on the interest detail page."
className="text-xs"
/>
</div>
);
}
function CreateCriterionDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const queryClient = useQueryClient();
const [key, setKey] = useState('');
const [label, setLabel] = useState('');
const [description, setDescription] = useState('');
const [enabled, setEnabled] = useState(true);
const mutation = useMutation({
mutationFn: async () =>
apiFetch('/api/v1/admin/qualification-criteria', {
method: 'POST',
body: {
key: key.trim(),
label: label.trim(),
description: description.trim() || null,
enabled,
displayOrder: 999,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] });
onOpenChange(false);
setKey('');
setLabel('');
setDescription('');
setEnabled(true);
},
onError: (err) => toastError(err),
});
const canSubmit =
key.trim().length > 0 &&
/^[a-z][a-z0-9_]*$/.test(key.trim()) &&
label.trim().length > 0 &&
!mutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add qualification criterion</DialogTitle>
<DialogDescription>
The <strong>key</strong> is a stable identifier code references (lowercase alphanumeric
+ underscores). It can&apos;t be changed once created per-interest state rows
reference it.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-1">
<div className="space-y-1">
<Label htmlFor="qc-key">Key</Label>
<Input
id="qc-key"
value={key}
onChange={(e) => setKey(e.target.value.toLowerCase())}
placeholder="e.g. budget_confirmed"
/>
{key && !/^[a-z][a-z0-9_]*$/.test(key) ? (
<p className="text-[11px] text-rose-700">
Must start with a letter; lowercase alphanumeric and underscores only.
</p>
) : null}
</div>
<div className="space-y-1">
<Label htmlFor="qc-label">Label</Label>
<Input
id="qc-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Budget confirmed"
/>
</div>
<div className="space-y-1">
<Label htmlFor="qc-desc">Description (optional)</Label>
<Textarea
id="qc-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Shown under the checkbox on the interest detail page."
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} />
Enabled by default
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button disabled={!canSubmit} onClick={() => mutation.mutate()}>
{mutation.isPending ? 'Adding…' : 'Add criterion'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -74,15 +74,13 @@ const KNOWN_SETTINGS: Array<{
description: 'Probability weights for revenue forecast by pipeline stage (JSON)',
type: 'json',
defaultValue: {
open: 0.05,
details_sent: 0.1,
in_communication: 0.2,
eoi_sent: 0.4,
eoi_signed: 0.6,
deposit_10pct: 0.75,
contract_sent: 0.85,
contract_signed: 0.95,
completed: 1.0,
enquiry: 0.05,
qualified: 0.15,
nurturing: 0.15,
eoi: 0.4,
reservation: 0.7,
deposit_paid: 0.85,
contract: 0.95,
},
},
{
@@ -92,6 +90,14 @@ const KNOWN_SETTINGS: Array<{
type: 'json',
defaultValue: [],
},
{
key: 'default_new_interest_owner',
label: 'Default New-Interest Owner',
description:
'User ID to auto-assign as the deal owner when a new interest is created. Stored as { "userId": "..." }. Leave blank to have new interests unassigned by default — the rep can pick an owner from the interest detail header.',
type: 'json',
defaultValue: { userId: null },
},
{
key: 'inquiry_contact_email',
label: 'Inquiry Contact Email',

View File

@@ -35,6 +35,7 @@ export function BerthList() {
setSort,
filters,
setFilter,
setAllFilters,
clearFilters,
setPage,
setPageSize,
@@ -62,35 +63,37 @@ export function BerthList() {
// No "New" button - berths are import-only
/>
<div className="flex items-center gap-2 flex-wrap">
<FilterBar
// Search is hoisted out of the popover into the inline input
// below — keeps the daily "find by mooring/area" lookup one
// tap away instead of buried behind the Filters dropdown.
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<Input
type="search"
inputMode="search"
placeholder="Search mooring or area…"
aria-label="Search berths"
value={(filters.search as string | undefined) ?? ''}
onChange={(e) => setFilter('search', e.target.value || undefined)}
// flex-1 + min-w-0 lets the input expand to fill the row's
// remaining width on mobile (where space is at a premium).
// sm:max-w-xs caps it at 320px on desktop so it doesn't grow
// absurdly wide on a 2k monitor.
className="h-8 min-w-0 flex-1 sm:max-w-xs"
/>
<div className="ml-auto flex items-center gap-2">
{/* Toolbar — two halves separated by `justify-between` so the
Columns + Saved-views actions stay pinned to the right edge of
the row at every width. The previous `ml-auto` trick didn't
survive flex-wrap on intermediate widths — the actions ended
up centered. */}
<div className="flex items-center gap-2 flex-wrap justify-between">
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
<FilterBar
// Search is hoisted out of the popover into the inline input
// below — keeps the daily "find by mooring/area" lookup one
// tap away instead of buried behind the Filters dropdown.
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<Input
type="search"
inputMode="search"
placeholder="Search mooring or area…"
aria-label="Search berths"
value={(filters.search as string | undefined) ?? ''}
onChange={(e) => setFilter('search', e.target.value || undefined)}
className="h-8 min-w-0 flex-1 sm:max-w-xs"
/>
</div>
<div className="flex items-center gap-2 ml-auto">
<SavedViewsDropdown
entityType="berths"
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
setAllFilters(savedFilters);
}}
/>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
@@ -78,6 +78,36 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
);
}
/**
* Tags Card for the berth overview. Wraps the InlineTagEditor in a Card so
* the section header uses CardTitle styling; mirrors the visibility rule
* the editor itself uses — hides entirely when the port has no tags
* defined AND this berth has none applied.
*/
function BerthTagsCard({ berth }: { berth: BerthData }) {
const { data: allTags } = useQuery<{ data: { id: string; name: string; color: string }[] }>({
queryKey: ['tags'],
queryFn: () => apiFetch('/api/v1/tags'),
staleTime: 60_000,
});
const portHasNoTags = allTags && allTags.data.length === 0;
if (portHasNoTags && berth.tags.length === 0) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<InlineTagEditor
endpoint={`/api/v1/berths/${berth.id}/tags`}
currentTags={berth.tags}
invalidateKey={['berths', berth.id]}
/>
</CardContent>
</Card>
);
}
function useBerthPatch(berthId: string) {
const qc = useQueryClient();
return useMutation({
@@ -382,18 +412,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<InlineTagEditor
endpoint={`/api/v1/berths/${berth.id}/tags`}
currentTags={berth.tags}
invalidateKey={['berths', berth.id]}
/>
</CardContent>
</Card>
<BerthTagsCard berth={berth} />
</div>
</div>
</div>

View File

@@ -2,7 +2,8 @@
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Pencil, Archive, Mail, MessageCircle, Phone } from 'lucide-react';
import { MoreHorizontal, Pencil, Archive, Mail, Phone } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
@@ -160,7 +161,7 @@ export function getClientColumns({
title={`WhatsApp ${value}`}
aria-label={`WhatsApp ${value}`}
>
<MessageCircle className="h-3.5 w-3.5" aria-hidden />
<WhatsAppIcon className="h-3.5 w-3.5" />
</a>
)}
</span>

View File

@@ -2,7 +2,8 @@
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Archive, Mail, MessageCircle, Phone, RotateCcw, Trash2 } from 'lucide-react';
import { Archive, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
@@ -126,7 +127,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
<MessageCircle />
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>

View File

@@ -236,7 +236,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<div className="space-y-3">
{fields.map((field, index) => (
<div key={field.id} className="space-y-3 p-3 rounded-lg border bg-muted/30">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-end sm:gap-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-start sm:gap-2">
<div className="space-y-1 sm:col-span-3">
<Label className="text-xs">Channel</Label>
<Select

View File

@@ -113,6 +113,9 @@ interface InterestDetail {
dateDepositReceived: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
eoiDocStatus: string | null;
reservationDocStatus: string | null;
contractDocStatus: string | null;
}
function useInterestDetail(id: string | null) {
@@ -272,12 +275,16 @@ function InterestPreviewSheet({
<ul>
<MilestoneRow
label="EOI sent"
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
done={reached('eoi') || !!fullDetail?.dateEoiSent}
date={formatDate(fullDetail?.dateEoiSent)}
/>
<MilestoneRow
label="EOI signed"
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
done={
fullDetail?.eoiDocStatus === 'signed' ||
stageIdx > PIPELINE_STAGES.indexOf('eoi') ||
!!fullDetail?.dateEoiSigned
}
date={formatDate(fullDetail?.dateEoiSigned)}
/>
</ul>
@@ -287,7 +294,7 @@ function InterestPreviewSheet({
<ul>
<MilestoneRow
label="Deposit received"
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
done={reached('deposit_paid') || !!fullDetail?.dateDepositReceived}
date={formatDate(fullDetail?.dateDepositReceived)}
/>
</ul>
@@ -297,12 +304,14 @@ function InterestPreviewSheet({
<ul>
<MilestoneRow
label="Contract sent"
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
done={reached('contract') || !!fullDetail?.dateContractSent}
date={formatDate(fullDetail?.dateContractSent)}
/>
<MilestoneRow
label="Contract signed"
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
done={
fullDetail?.contractDocStatus === 'signed' || !!fullDetail?.dateContractSigned
}
date={formatDate(fullDetail?.dateContractSigned)}
/>
</ul>

View File

@@ -78,6 +78,7 @@ export function ClientList() {
setPageSize,
filters,
setFilter,
setAllFilters,
clearFilters,
} = usePaginatedQuery<ClientRow>({
queryKey: ['clients'],
@@ -152,8 +153,11 @@ export function ClientList() {
<SavedViewsDropdown
entityType="clients"
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
// Atomic replace — sequential setFilter() calls dropped all
// but the last value (each one read stale `filters` from
// closure and overwrote). setAllFilters writes the whole
// saved view in one setState.
setAllFilters(savedFilters);
}}
/>
<ColumnPicker

View File

@@ -219,15 +219,12 @@ function OverviewTab({
</dl>
</div>
{/* Tags */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
</div>
<InlineTagEditor
heading="Tags"
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
</div>
</div>
);

View File

@@ -2,16 +2,8 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Loader2,
Mail,
MessageSquare,
MoreHorizontal,
Phone,
Plus,
Star,
Trash2,
} from 'lucide-react';
import { Loader2, Mail, MoreHorizontal, Phone, Plus, Star, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -51,7 +43,7 @@ const CHANNEL_OPTIONS = [
const CHANNEL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
email: Mail,
phone: Phone,
whatsapp: MessageSquare,
whatsapp: WhatsAppIcon,
other: MoreHorizontal,
};

View File

@@ -86,6 +86,7 @@ export function CompanyList() {
setPageSize,
filters,
setFilter,
setAllFilters,
clearFilters,
} = usePaginatedQuery<CompanyRow>({
queryKey: ['companies'],
@@ -144,8 +145,7 @@ export function CompanyList() {
<SavedViewsDropdown
entityType="companies"
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
setAllFilters(savedFilters);
}}
/>
<ColumnPicker columns={COMPANY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />

View File

@@ -170,15 +170,13 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
</dl>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/companies/${companyId}/tags`}
currentTags={company.tags ?? []}
invalidateKey={['companies', companyId]}
/>
</div>
<InlineTagEditor
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/companies/${companyId}/tags`}
currentTags={company.tags ?? []}
invalidateKey={['companies', companyId]}
/>
</div>
);
}

View File

@@ -24,19 +24,17 @@ interface HotDealsResponse {
}
// Local label map intentionally narrowed to the stages this widget
// surfaces. Keys MUST match the canonical DB values (deposit_10pct +
// in_communication) — the reporting audit caught typos that broke the
// rank ladder server-side AND rendered raw enum to the user.
// surfaces. Keys MUST match the canonical DB values for the 7-stage
// pipeline (post-2026-05 refactor) — the reporting audit caught typos
// that broke the rank ladder server-side AND rendered raw enum to the user.
const STAGE_LABELS: Record<string, string> = {
contract_signed: 'Contract Signed',
contract_sent: 'Contract Sent',
deposit_10pct: 'Deposit 10%',
eoi_signed: 'EOI Signed',
eoi_sent: 'EOI Sent',
in_communication: 'In Comms',
details_sent: 'Details Sent',
open: 'Open',
completed: 'Completed',
contract: 'Contract',
deposit_paid: 'Deposit Paid',
reservation: 'Reservation',
eoi: 'EOI',
nurturing: 'Nurturing',
qualified: 'Qualified',
enquiry: 'Enquiry',
};
/**

View File

@@ -1,59 +1,66 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
import { EXPENSE_CATEGORIES, formatEnum } from '@/lib/constants';
export const expenseFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by establishment or description...',
},
{
key: 'category',
label: 'Category',
type: 'multi-select',
options: EXPENSE_CATEGORIES.map((c) => ({
label: formatEnum(c),
value: c,
})),
},
{
key: 'paymentStatus',
label: 'Payment Status',
type: 'select',
options: [
{ label: 'Unpaid', value: 'unpaid' },
{ label: 'Paid', value: 'paid' },
{ label: 'Partial', value: 'partial' },
],
},
{
key: 'dateFrom',
label: 'Date From',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'dateTo',
label: 'Date To',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'currency',
label: 'Currency',
type: 'text',
placeholder: 'e.g. USD, EUR',
},
{
key: 'tripLabel',
label: 'Trip / event',
type: 'text',
placeholder: 'e.g. Palm Beach 2026',
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];
/**
* Build the filter-bar definitions. Categories accept the resolved
* per-port vocabulary list when callers can fetch it; otherwise the
* shipped defaults are used. Kept as a function so the page can read
* `/api/v1/vocabularies` on mount and reactively rebuild.
*/
export function buildExpenseFilterDefinitions(
categories: readonly string[] = EXPENSE_CATEGORIES,
): FilterDefinition[] {
return [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by establishment or description...',
},
{
key: 'category',
label: 'Category',
type: 'multi-select',
options: categories.map((c) => ({ label: formatEnum(c), value: c })),
},
{
key: 'paymentStatus',
label: 'Payment Status',
type: 'select',
options: [
{ label: 'Unpaid', value: 'unpaid' },
{ label: 'Paid', value: 'paid' },
{ label: 'Partial', value: 'partial' },
],
},
{
key: 'dateFrom',
label: 'Date From',
type: 'date',
},
{
key: 'dateTo',
label: 'Date To',
type: 'date',
},
{
key: 'currency',
label: 'Currency',
type: 'currency',
},
{
key: 'tripLabel',
label: 'Trip / event',
type: 'text',
placeholder: 'e.g. Palm Beach 2026',
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];
}
/** Default list used by SSR / non-vocab-aware consumers. */
export const expenseFilterDefinitions: FilterDefinition[] = buildExpenseFilterDefinitions();

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -22,6 +22,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com
import { CurrencyInput } from '@/components/shared/currency-input';
import { CurrencySelect } from '@/components/shared/currency-select';
import { TripLabelCombobox } from '@/components/expenses/trip-label-combobox';
import { UserPicker } from '@/components/shared/user-picker';
import { apiFetch } from '@/lib/api/client';
import type { z } from 'zod';
import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
@@ -42,6 +43,17 @@ interface ExpenseFormDialogProps {
export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDialogProps) {
const queryClient = useQueryClient();
const isEdit = !!expense;
// Per-port vocabulary override for expense categories. Falls back to
// the shipped EXPENSE_CATEGORIES constant when /api/v1/vocabularies
// hasn't loaded yet or returns malformed data — keeps the picker
// populated during the first render.
const { data: vocab } = useQuery<{ data: Record<string, readonly string[]> }>({
queryKey: ['vocabularies'],
queryFn: () => apiFetch('/api/v1/vocabularies'),
staleTime: 5 * 60_000,
});
const categoryList = vocab?.data?.expense_categories ?? EXPENSE_CATEGORIES;
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadedReceipt, setUploadedReceipt] = useState<UploadedReceipt | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@@ -253,7 +265,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((cat) => (
{categoryList.map((cat) => (
<SelectItem key={cat} value={cat}>
{formatEnum(cat)}
</SelectItem>
@@ -285,7 +297,14 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
<div className="space-y-2">
<Label htmlFor="payer">Payer</Label>
<Input id="payer" placeholder="Who paid?" {...register('payer')} />
<UserPicker
value={(watch('payer') as string | undefined) ?? null}
onChange={(v) => setValue('payer', v ?? '', { shouldDirty: true })}
placeholder="Who paid?"
/>
<p className="text-xs text-muted-foreground">
Pick a teammate or choose &ldquo;Other&hellip;&rdquo; to type any name.
</p>
</div>
<div className="space-y-2">

View File

@@ -0,0 +1,31 @@
import { type SVGProps } from 'react';
/**
* WhatsApp brand glyph. Lucide doesn't ship one because the WhatsApp
* logo is trademarked, but inline use is permitted for legitimate
* channel-indicator UI (no implication of endorsement). The shape is
* the standard speech-bubble + handset silhouette.
*
* Sized via `size`/`className` like other lucide icons so callers can
* use it as a drop-in (`<WhatsAppIcon className="h-4 w-4" />`).
*/
export function WhatsAppIcon({
size = 24,
className,
...props
}: SVGProps<SVGSVGElement> & { size?: number | string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
fill="currentColor"
className={className}
aria-hidden="true"
{...props}
>
<path d="M12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 2.1.65 4.05 1.75 5.66L2 22l4.55-1.86a9.93 9.93 0 0 0 5.49 1.66h.01c5.46 0 9.91-4.45 9.91-9.91 0-2.65-1.03-5.14-2.9-7.01A9.85 9.85 0 0 0 12.04 2zm0 18.14h-.01a8.2 8.2 0 0 1-4.18-1.15l-.3-.18-3.1 1.27.83-3.02-.2-.31a8.16 8.16 0 0 1-1.25-4.36c0-4.52 3.68-8.2 8.21-8.2 2.19 0 4.25.86 5.8 2.4a8.17 8.17 0 0 1 2.4 5.8c0 4.53-3.68 8.2-8.2 8.2zm4.5-6.14c-.25-.12-1.46-.72-1.69-.8-.23-.08-.39-.12-.56.12-.16.25-.64.8-.78.97-.14.16-.29.18-.54.06-.25-.12-1.04-.38-1.98-1.22-.73-.65-1.23-1.46-1.37-1.7-.14-.25-.02-.39.11-.51.11-.11.25-.29.37-.43.13-.14.16-.25.25-.41.08-.16.04-.31-.02-.43-.06-.12-.56-1.34-.76-1.83-.2-.49-.41-.42-.56-.43-.14-.01-.31-.01-.48-.01-.16 0-.43.06-.66.31-.23.25-.86.84-.86 2.05 0 1.21.88 2.37 1 2.54.12.16 1.74 2.66 4.22 3.73.59.25 1.05.41 1.41.52.59.19 1.13.16 1.55.1.47-.07 1.46-.6 1.66-1.17.2-.58.2-1.07.14-1.17-.06-.1-.22-.16-.47-.28z" />
</svg>
);
}

View File

@@ -88,7 +88,7 @@ export function AddBerthToInterestDialog({
checked={choice === 'exploring'}
title="Just exploring"
description="The berth is being considered or covered by the EOI bundle, but not pitched specifically."
consequence="This berth is hidden from the public map."
consequence="This berth stays marked “Available” on the public map — the link is internal only."
icon={<EyeOff className="size-4" aria-hidden />}
/>
</RadioGroup>

View File

@@ -0,0 +1,124 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { UserCircle2 } from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface UserOption {
id: string;
displayName: string | null;
}
/**
* Click-to-edit ownership chip for the interest detail header. Stores the
* assignee's user-id on `interests.assigned_to`. The "Unassigned" path writes
* null so the chip falls back to a muted ghost state.
*/
export function AssignedToChip({
interestId,
currentAssignedTo,
currentAssignedToName,
}: {
interestId: string;
currentAssignedTo: string | null;
currentAssignedToName: string | null;
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const { data } = useQuery<{ data: UserOption[] }>({
queryKey: ['user-options'],
queryFn: () => apiFetch('/api/v1/admin/users/options'),
staleTime: 5 * 60_000,
enabled: open,
});
const users = data?.data ?? [];
const mutation = useMutation({
mutationFn: (next: string | null) =>
apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: { assignedTo: next },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setOpen(false);
},
onError: (err) => {
toastError(err);
},
});
const label = currentAssignedToName
? currentAssignedToName
: currentAssignedTo
? `User ${currentAssignedTo.slice(0, 8)}`
: 'Unassigned';
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Change deal owner (currently ${label})`}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
currentAssignedTo
? 'border-sky-200 bg-sky-50 text-sky-800 hover:bg-sky-100'
: 'border-border bg-muted/50 text-muted-foreground hover:bg-muted',
)}
>
<UserCircle2 className="size-3" aria-hidden />
{label}
</button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command>
<CommandInput placeholder="Search users…" />
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup heading="Assign to">
{users.map((u) => (
<CommandItem
key={u.id}
value={u.displayName ?? u.id}
onSelect={() => mutation.mutate(u.id)}
disabled={mutation.isPending}
>
{u.displayName ?? u.id.slice(0, 8)}
</CommandItem>
))}
</CommandGroup>
{currentAssignedTo ? (
<CommandGroup heading="Or">
<CommandItem
value="__unassign__"
onSelect={() => mutation.mutate(null)}
className="text-muted-foreground"
disabled={mutation.isPending}
>
Unassign
</CommandItem>
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -345,6 +345,10 @@ export function BerthRecommenderPanel({
const [amenityFilters, setAmenityFilters] = useState<AmenityFilters>({});
const [showAll, setShowAll] = useState(false);
const [pendingBerth, setPendingBerth] = useState<Recommendation | null>(null);
// Area-letter filter — chips above the list let reps narrow to a
// single pier (e.g. "show me only A-row matches"). Client-side over
// the already-fetched result set; no service change required.
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
const hasDimensions = desiredLengthFt !== null;
@@ -367,7 +371,27 @@ export function BerthRecommenderPanel({
staleTime: 60_000,
});
const recommendations = data ?? [];
const allRecommendations = data ?? [];
// Build the set of dock-letter chips from whatever came back, then
// filter the visible recommendations by the active selection. Empty
// selection = show everything (default).
const areaChips = useMemo(() => {
const set = new Set<string>();
for (const r of allRecommendations) {
const m = r.mooringNumber.match(/^([A-Z]+)/);
if (m?.[1]) set.add(m[1]);
}
return Array.from(set).sort();
}, [allRecommendations]);
const recommendations =
selectedAreas.length === 0
? allRecommendations
: allRecommendations.filter((r) => {
const m = r.mooringNumber.match(/^([A-Z]+)/);
return m?.[1] ? selectedAreas.includes(m[1]) : false;
});
return (
<Card>
@@ -410,6 +434,43 @@ export function BerthRecommenderPanel({
{filtersOpen && hasDimensions ? (
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
) : null}
{hasDimensions && areaChips.length > 1 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-muted-foreground">Area:</span>
{areaChips.map((letter) => {
const active = selectedAreas.includes(letter);
return (
<button
key={letter}
type="button"
onClick={() =>
setSelectedAreas((prev) =>
prev.includes(letter) ? prev.filter((l) => l !== letter) : [...prev, letter],
)
}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background text-foreground hover:bg-muted',
)}
aria-pressed={active}
>
{letter}
</button>
);
})}
{selectedAreas.length > 0 ? (
<button
type="button"
onClick={() => setSelectedAreas([])}
className="text-xs text-muted-foreground underline ml-1"
>
Clear
</button>
) : null}
</div>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{!hasDimensions ? (

View File

@@ -0,0 +1,78 @@
'use client';
import { Activity } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800',
warm: 'border-amber-200 bg-amber-50 text-amber-800',
cold: 'border-rose-200 bg-rose-50 text-rose-800',
};
const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
hot: 'Hot',
warm: 'Warm',
cold: 'Cold',
};
/**
* Header chip surfacing the rule-based deal-health score. The tooltip
* exposes every signal that contributed to the score so the calculation is
* transparent — stakeholders averse to AI black boxes can read exactly
* which dates / stages drove the verdict.
*/
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
// Closed / archived deals don't get a pulse — UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
const health = computeDealHealth(interest);
const tint = PULSE_TINT[health.pulse];
const label = PULSE_LABEL[health.pulse];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium cursor-help',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="font-semibold mb-1.5">
Deal pulse {label} ({health.score}/100)
</p>
{health.signals.length === 0 ? (
<p className="text-xs">
Baseline score (50) nothing notable yet. Log contact or progress the stage to move
the dial.
</p>
) : (
<ul className="space-y-1 text-xs">
{health.signals.map((s) => (
<li key={s.id} className="flex gap-2">
<span className={s.delta > 0 ? 'text-emerald-300' : 'text-rose-300'}>
{s.delta > 0 ? `+${s.delta}` : s.delta}
</span>
<span>{s.detail}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-[10px] opacity-70">
Rule-based. Every signal traces to a date or stage you can see no AI.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -121,11 +121,11 @@ export function InlineStagePicker({
setOpen(false);
return;
}
// Rewind-to-open guard: if the rep is dropping the stage back to
// 'open' AND the interest still has linked berths, intercept to ask
// Rewind-to-enquiry guard: if the rep is dropping the stage back to
// 'enquiry' AND the interest still has linked berths, intercept to ask
// whether to unlink them. Skipped when there are no linked berths
// (the prompt would be noise) or when the rep already came from open.
if (next === 'open' && stage !== 'open' && linkedBerthCount > 0) {
// (the prompt would be noise) or when the rep is already at enquiry.
if (next === 'enquiry' && stage !== 'enquiry' && linkedBerthCount > 0) {
setOpenConfirmTarget(next);
setOpen(false);
return;

View File

@@ -0,0 +1,76 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface BerthRow {
id: string;
mooringNumber: string;
status: string;
isPrimary: boolean;
}
interface BerthsResponse {
data: BerthRow[];
}
/**
* Surfaces when one of the interest's linked berths is sold or under offer
* to a different deal. We don't block the rep from proceeding (the user
* explicitly wanted v1 to still let the deal advance — the assumption is
* that the rep is aware and treating the current deal as a fallback if
* the other one falls through), but the banner makes the conflict visible
* so they aren't surprised when the rules engine flags it.
*
* Fires only for active (non-archived, non-closed) interests — banners on
* lost deals are noise.
*/
export function InterestBerthStatusBanner({
interestId,
interestPipelineStage,
interestOutcome,
archivedAt,
}: {
interestId: string;
interestPipelineStage: string;
interestOutcome?: string | null;
archivedAt?: string | null;
}) {
const { data } = useQuery<BerthsResponse>({
queryKey: ['interest-berths', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
});
if (archivedAt || interestOutcome) return null;
// The banner is most useful before the rep is committed to the deal —
// once contract is in motion, the conflict is moot.
if (interestPipelineStage === 'contract') return null;
const berths = data?.data ?? [];
const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer');
if (conflicts.length === 0) return null;
return (
<div
role="status"
className="flex items-start gap-2 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-900"
>
<AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
<div>
<p className="font-medium">
{conflicts.length === 1
? `Berth ${conflicts[0]!.mooringNumber} is ${
conflicts[0]!.status === 'sold' ? 'Sold' : 'Under Offer'
} to another deal.`
: `${conflicts.length} linked berths are no longer freely available.`}
</p>
<p className="mt-0.5 text-rose-800">
You can still progress this interest as a backup, but the rep on the other deal owns the
primary path. If their deal falls through, this one can step in.
</p>
</div>
</div>
);
}

View File

@@ -1,12 +1,13 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bell,
CalendarDays,
Mail,
MessageCircle,
Mic,
MicOff,
MoreVertical,
Phone,
Pencil,
@@ -15,6 +16,8 @@ import {
Users,
Video,
} from 'lucide-react';
import { useVoiceTranscription } from '@/hooks/use-voice-transcription';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { toast } from 'sonner';
@@ -58,12 +61,16 @@ interface InterestContactLogTabProps {
type Channel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
type Direction = 'outbound' | 'inbound';
type Template = 'call' | 'visit' | 'email';
interface ContactLogEntry {
id: string;
occurredAt: string;
channel: Channel;
direction: Direction;
summary: string;
voiceTranscript: string | null;
templateUsed: string | null;
followUpAt: string | null;
reminderId: string | null;
createdBy: string;
@@ -71,10 +78,40 @@ interface ContactLogEntry {
updatedAt: string;
}
const CHANNEL_META: Record<Channel, { label: string; icon: typeof Phone; tone: string }> = {
/** Quick-template seeds — drop a starting structure into the summary so reps
* spend their typing on the substance, not the scaffolding. */
const TEMPLATE_SEEDS: Record<
Template,
{ channel: Channel; direction: Direction; summary: string; label: string; icon: ChannelIcon }
> = {
call: {
channel: 'phone',
direction: 'outbound',
summary: 'Called the client. Discussed:\n\n• \n\nNext step: ',
label: 'Call',
icon: Phone,
},
visit: {
channel: 'in_person',
direction: 'outbound',
summary: 'Met with the client in person. Discussed:\n\n• \n\nNext step: ',
label: 'Visit',
icon: Users,
},
email: {
channel: 'email',
direction: 'outbound',
summary: 'Emailed the client.\n\nTopic: \n\nResponse expected: ',
label: 'Email',
icon: Mail,
},
};
type ChannelIcon = React.ComponentType<{ className?: string }>;
const CHANNEL_META: Record<Channel, { label: string; icon: ChannelIcon; tone: string }> = {
email: { label: 'Email', icon: Mail, tone: 'bg-sky-100 text-sky-700' },
phone: { label: 'Phone', icon: Phone, tone: 'bg-emerald-100 text-emerald-700' },
whatsapp: { label: 'WhatsApp', icon: MessageCircle, tone: 'bg-emerald-100 text-emerald-700' },
whatsapp: { label: 'WhatsApp', icon: WhatsAppIcon, tone: 'bg-emerald-100 text-emerald-700' },
in_person: { label: 'In person', icon: Users, tone: 'bg-amber-100 text-amber-800' },
video: { label: 'Video', icon: Video, tone: 'bg-violet-100 text-violet-700' },
other: { label: 'Other', icon: CalendarDays, tone: 'bg-slate-100 text-slate-700' },
@@ -306,6 +343,44 @@ function ComposeDialogBody({
const [followUpAt, setFollowUpAt] = useState<string>(
existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
);
const [templateUsed, setTemplateUsed] = useState<Template | null>(
(existing?.templateUsed as Template | undefined) ?? null,
);
// Voice transcript is captured separately so an edit to summary doesn't
// overwrite the rep's original raw utterance. Preserved on the row.
const [voiceTranscript, setVoiceTranscript] = useState<string>(existing?.voiceTranscript ?? '');
const voice = useVoiceTranscription();
// Append committed transcript chunks into the summary as the rep speaks.
// We diff against the previous final transcript so we only append the new
// tail — otherwise the entire transcript gets re-pasted on every event.
const previousFinalRef = useRef<string>('');
useEffect(() => {
const prev = previousFinalRef.current;
if (voice.transcript === prev) return;
const added = voice.transcript.slice(prev.length).trim();
if (added.length === 0) {
previousFinalRef.current = voice.transcript;
return;
}
setSummary((prevSummary) => {
const sep =
prevSummary && !prevSummary.endsWith(' ') && !prevSummary.endsWith('\n') ? ' ' : '';
return prevSummary + sep + added;
});
setVoiceTranscript((prev2) => (prev2 ? `${prev2} ${added}` : added));
previousFinalRef.current = voice.transcript;
}, [voice.transcript]);
function applyTemplate(t: Template) {
const seed = TEMPLATE_SEEDS[t];
setChannel(seed.channel);
setDirection(seed.direction);
// Don't clobber if the rep already typed something — append a divider
// so the template scaffolds the *next* block.
setSummary((cur) => (cur.trim().length === 0 ? seed.summary : `${cur}\n\n${seed.summary}`));
setTemplateUsed(t);
}
const mutation = useMutation({
mutationFn: async () => {
@@ -314,6 +389,8 @@ function ComposeDialogBody({
channel,
direction,
summary,
voiceTranscript: voiceTranscript.trim().length > 0 ? voiceTranscript : null,
templateUsed,
followUpAt: followUpAt ? new Date(followUpAt).toISOString() : null,
};
if (isEdit) {
@@ -350,6 +427,35 @@ function ComposeDialogBody({
</DialogHeader>
<div className="space-y-3 py-1">
{/* Quick-template buttons. Tap one to seed the channel + direction
+ a starter summary so the rep can focus on the substance.
Hidden when editing — templates are a fresh-entry affordance. */}
{!isEdit ? (
<div className="flex flex-wrap gap-1.5">
{(Object.keys(TEMPLATE_SEEDS) as Template[]).map((t) => {
const seed = TEMPLATE_SEEDS[t];
const Icon = seed.icon;
const active = templateUsed === t;
return (
<button
key={t}
type="button"
onClick={() => applyTemplate(t)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors',
active
? 'border-sky-300 bg-sky-50 text-sky-800'
: 'border-border bg-muted/40 text-foreground hover:bg-muted',
)}
>
<Icon className="size-3" aria-hidden />
{seed.label}
</button>
);
})}
</div>
) : null}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="cl-channel">Channel</Label>
@@ -391,7 +497,44 @@ function ComposeDialogBody({
</div>
<div className="space-y-1">
<Label htmlFor="cl-summary">Summary</Label>
<div className="flex items-center justify-between">
<Label htmlFor="cl-summary">Summary</Label>
{voice.supported ? (
<button
type="button"
aria-label={
voice.isListening ? 'Stop voice transcription' : 'Start voice transcription'
}
onClick={() => (voice.isListening ? voice.stop() : voice.start())}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
voice.isListening
? 'border-rose-300 bg-rose-50 text-rose-800 animate-pulse'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted',
)}
>
{voice.isListening ? (
<>
<Mic className="size-3" aria-hidden />
Recording
</>
) : (
<>
<MicOff className="size-3" aria-hidden />
Voice
</>
)}
</button>
) : (
<span
title="Voice transcription isn't supported in this browser."
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground"
>
<MicOff className="size-3" aria-hidden />
Voice unavailable
</span>
)}
</div>
<Textarea
id="cl-summary"
placeholder="e.g. Confirmed yacht size, asked about tax structure, said they'll respond after their accountant reviews."
@@ -399,6 +542,12 @@ function ComposeDialogBody({
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
{voice.isListening && voice.interim ? (
<p className="text-[11px] italic text-muted-foreground">{voice.interim}</p>
) : null}
{voice.error ? (
<p className="text-[11px] text-rose-700">Voice error: {voice.error}</p>
) : null}
</div>
<div className="space-y-2 rounded-md border bg-muted/30 p-3">

View File

@@ -10,10 +10,10 @@ import {
XCircle,
RefreshCcw,
Mail,
MessageCircle,
Phone,
AlarmClock,
} from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
@@ -25,6 +25,9 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { AssignedToChip } from '@/components/interests/assigned-to-chip';
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client';
import { formatOutcome } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -86,6 +89,21 @@ interface InterestDetailHeaderProps {
outcome?: string | null;
outcomeReason?: string | null;
dateLastContact?: string | null;
dateFirstContact?: string | null;
dateEoiSent?: string | null;
dateEoiSigned?: string | null;
dateReservationSigned?: string | null;
dateContractSent?: string | null;
dateContractSigned?: string | null;
dateDepositReceived?: string | null;
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
/** Activity-log entries in the last 7 days — drives deal-pulse +5 signal. */
recentActivityCount?: number | null;
/** Sales rep who owns this deal — populated by the AssignedToChip. */
assignedTo?: string | null;
assignedToName?: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
@@ -235,6 +253,33 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount}
</span>
) : null}
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
<MultiEoiChip interestId={interest.id} />
<DealPulseChip
interest={{
pipelineStage: interest.pipelineStage,
outcome: interest.outcome,
archivedAt: interest.archivedAt,
dateFirstContact: interest.dateFirstContact,
dateLastContact: interest.dateLastContact,
dateEoiSent: interest.dateEoiSent,
dateEoiSigned: interest.dateEoiSigned,
dateReservationSigned: interest.dateReservationSigned,
dateContractSent: interest.dateContractSent,
dateContractSigned: interest.dateContractSigned,
dateDepositReceived: interest.dateDepositReceived,
eoiDocStatus: interest.eoiDocStatus,
reservationDocStatus: interest.reservationDocStatus,
contractDocStatus: interest.contractDocStatus,
recentActivityCount: interest.recentActivityCount,
}}
/>
</div>
{meta.length > 0 ? (
@@ -311,7 +356,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<MessageCircle />
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>

View File

@@ -102,7 +102,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
defaultValues: {
clientId: '',
yachtId: undefined,
pipelineStage: 'open',
pipelineStage: 'enquiry',
reminderEnabled: false,
tagIds: [],
},
@@ -189,7 +189,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
reset({
clientId: defaultClientId ?? '',
yachtId: undefined,
pipelineStage: 'open',
pipelineStage: 'enquiry',
reminderEnabled: false,
tagIds: [],
});
@@ -389,7 +389,9 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Yacht</Label>
<Label>
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
{selectedClientId && (
<Button
type="button"

View File

@@ -82,7 +82,7 @@ export function InterestList() {
// Bulk-action dialog state
const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null);
const [stageChoice, setStageChoice] = useState<PipelineStage>('open');
const [stageChoice, setStageChoice] = useState<PipelineStage>('enquiry');
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
null,
);
@@ -99,6 +99,7 @@ export function InterestList() {
setPageSize,
filters,
setFilter,
setAllFilters,
clearFilters,
} = usePaginatedQuery<InterestRow>({
queryKey: ['interests'],
@@ -237,8 +238,7 @@ export function InterestList() {
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
@@ -284,7 +284,7 @@ export function InterestList() {
icon: ArrowRight,
onClick: (ids) => {
if (ids.length === 0) return;
setStageChoice('open');
setStageChoice('enquiry');
setStageDialog({ ids });
},
},

View File

@@ -5,7 +5,7 @@ import { useParams } from 'next/navigation';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
@@ -18,6 +18,8 @@ import { RecommendationList } from '@/components/interests/recommendation-list';
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { WonStatusPanel } from '@/components/interests/won-status-panel';
import { SupplementalInfoRequestButton } from '@/components/interests/supplemental-info-request-button';
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
import {
LEAD_CATEGORIES,
@@ -28,6 +30,10 @@ import {
} from '@/lib/constants';
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
import { PaymentsSection } from '@/components/interests/payments-section';
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -65,10 +71,23 @@ interface InterestTabsOptions {
contractStatus: string | null;
depositStatus: string | null;
reservationStatus: string | null;
/** Captured at reservation-agreement time. Drives the deposit-paid
* auto-advance once payment totals catch up. */
depositExpectedAmount?: string | null;
depositExpectedCurrency?: string | null;
/** Doc-bearing stage sub-status badges — drive the milestone past/current
* classification independently of the pipeline stage. NULL until the
* matching stage is reached. */
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
/** Final outcome — 'won' surfaces the wrap-up checklist panel. */
outcome?: string | null;
dateFirstContact: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateReservationSigned?: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
dateDepositReceived: string | null;
@@ -401,7 +420,7 @@ function FutureMilestones({
currentStage,
}: {
milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
title: string;
icon: React.ComponentType<{ className?: string }>;
status: string | null;
@@ -410,7 +429,7 @@ function FutureMilestones({
}>;
stageMutation: ReturnType<typeof useStageMutation>;
advance: (stage: string) => void | Promise<void>;
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null;
currentStage: string;
}) {
const [expanded, setExpanded] = useState(false);
@@ -511,17 +530,19 @@ function OverviewTab({
// genuinely skips stages — the click then routes through the same
// override-confirm flow as the inline stage picker.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const contractIdx = PIPELINE_STAGES.indexOf('contract');
// Sub-status carries the "is this milestone's doc actually signed?" bit
// for the doc-bearing stages (eoi / reservation / contract). A milestone
// is 'past' when stage is BEYOND its index OR when stage equals its index
// AND the doc sub-status is 'signed'.
const eoiSigned = interest.eoiDocStatus === 'signed';
const reservationSigned = interest.reservationDocStatus === 'signed';
const contractSigned = interest.contractDocStatus === 'signed';
const phaseFor = (milestoneEndStageIdx: number): Phase => {
if (stageIdx === -1) return 'future';
if (stageIdx >= milestoneEndStageIdx) return 'past';
// The "current" milestone is the one whose end-stage hasn't been
// reached and whose start-stage is at-or-before the current stage.
return 'current';
};
// Berth Interest milestone — first thing the rep needs to capture
// (especially for general_interest leads). Completes the moment ANY
// berth is linked to the interest via the junction. While unset, it
@@ -531,39 +552,59 @@ function OverviewTab({
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
const berthInterestPhase: Phase = hasLinkedBerth
? 'past'
: stageIdx === -1 || stageIdx >= eoiSignedIdx
: stageIdx === -1 || stageIdx >= eoiIdx
? 'past'
: 'current';
const eoiPhase = phaseFor(eoiSignedIdx);
// Deposit is current once the EOI is signed but before deposit is in.
const eoiPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > eoiIdx || (stageIdx === eoiIdx && eoiSigned)
? 'past'
: stageIdx === eoiIdx
? 'current'
: 'future';
const reservationPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > reservationIdx || (stageIdx === reservationIdx && reservationSigned)
? 'past'
: stageIdx === reservationIdx
? 'current'
: 'future';
// Deposit becomes 'current' once the reservation is signed; auto-advance
// moves it to 'past' the moment the running deposit total catches up.
const depositPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= depositIdx
: stageIdx > depositIdx
? 'past'
: stageIdx >= eoiSignedIdx
? 'current'
: 'future';
: stageIdx === depositIdx
? 'past'
: stageIdx === reservationIdx && reservationSigned
? 'current'
: 'future';
const contractPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= contractSignedIdx
: stageIdx === contractIdx && contractSigned
? 'past'
: stageIdx >= depositIdx
: stageIdx === contractIdx
? 'current'
: 'future';
const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null =
const activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null =
berthInterestPhase === 'current'
? 'berth_interest'
: eoiPhase === 'current'
? 'eoi'
: depositPhase === 'current'
? 'deposit'
: contractPhase === 'current'
? 'contract'
: null;
: reservationPhase === 'current'
? 'reservation'
: depositPhase === 'current'
? 'deposit'
: contractPhase === 'current'
? 'contract'
: null;
const toNum = (v: string | null | undefined): number | null => {
if (v === null || v === undefined) return null;
@@ -572,7 +613,7 @@ function OverviewTab({
};
const milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
phase: Phase;
title: string;
icon: React.ComponentType<{ className?: string }>;
@@ -612,18 +653,20 @@ function OverviewTab({
phase: eoiPhase,
title: 'EOI',
icon: Send,
status: interest.eoiStatus,
status: interest.eoiDocStatus ?? interest.eoiStatus,
steps: [
{
label: 'EOI sent',
date: interest.dateEoiSent,
advanceStage: 'eoi_sent',
advanceStage: 'eoi',
actionLabel: 'Mark EOI as sent',
},
{
label: 'EOI signed',
date: interest.dateEoiSigned,
advanceStage: 'eoi_signed',
// Stage stays at 'eoi'; the sub-status badge flips via a separate
// PATCH (see MilestoneAdvanceButton.onConfirm fallback below).
advanceStage: 'eoi',
actionLabel: 'Mark EOI as signed',
},
],
@@ -631,6 +674,24 @@ function OverviewTab({
? `Signed ${formatDate(interest.dateEoiSigned)}`
: 'Completed',
},
{
key: 'reservation',
phase: reservationPhase,
title: 'Reservation',
icon: FileSignature,
status: interest.reservationDocStatus ?? null,
steps: [
{
label: 'Reservation agreement signed',
date: interest.dateReservationSigned ?? null,
advanceStage: 'reservation',
actionLabel: 'Mark reservation as signed',
},
],
pastSummary: interest.dateReservationSigned
? `Signed ${formatDate(interest.dateReservationSigned)}`
: 'Completed',
},
{
key: 'deposit',
phase: depositPhase,
@@ -641,25 +702,22 @@ function OverviewTab({
{
label: 'Deposit received',
date: interest.dateDepositReceived,
advanceStage: 'deposit_10pct',
advanceStage: 'deposit_paid',
hideAutoButton: true,
},
],
footer:
depositPhase === 'current' && !interest.dateDepositReceived ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
<Plus className="size-3.5" aria-hidden />
Create deposit invoice
</Link>
</Button>
<MilestoneAdvanceButton
label="Mark received manually"
variant="ghostLink"
disabled={stageMutation.isPending}
onConfirm={(date) => advance('deposit_10pct', date)}
onConfirm={(date) => advance('deposit_paid', date)}
/>
<span className="text-[11px] text-muted-foreground">
Or record a payment in the Payments section.
</span>
</div>
) : null,
pastSummary: interest.dateDepositReceived
@@ -671,18 +729,18 @@ function OverviewTab({
phase: contractPhase,
title: 'Contract',
icon: FileSignature,
status: interest.contractStatus,
status: interest.contractDocStatus ?? interest.contractStatus,
steps: [
{
label: 'Contract sent',
date: interest.dateContractSent,
advanceStage: 'contract_sent',
advanceStage: 'contract',
actionLabel: 'Mark contract as sent',
},
{
label: 'Contract signed',
date: interest.dateContractSigned,
advanceStage: 'contract_signed',
advanceStage: 'contract',
actionLabel: 'Mark contract as signed',
},
],
@@ -698,6 +756,35 @@ function OverviewTab({
return (
<div className="space-y-6">
{/* Skip-ahead nudge — informational only; fires when the deal jumped
past a milestone without stamping the matching date. */}
<SkipAheadBanner interest={interest} />
{/* Conflict callout — fires when a linked berth is sold or already
under offer to another active deal. Doesn't block the rep; just
surfaces the situation so they treat the deal as a backup. */}
<InterestBerthStatusBanner
interestId={interestId}
interestPipelineStage={interest.pipelineStage}
interestOutcome={interest.outcome}
archivedAt={null}
/>
{/* Qualification checklist — surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */}
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
{/* Payments — bank-issued invoices live elsewhere; this is the
internal audit record of money received against the deal. The
running deposit total here drives the auto-advance into the
deposit_paid stage server-side. */}
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future
@@ -842,21 +929,30 @@ function OverviewTab({
)}
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
</div>
<InlineTagEditor
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
</div>
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
what's already linked before browsing more options. Each row exposes
per-berth role-flag toggles and the EOI bypass control (only visible
once the parent interest's primary EOI is signed). */}
{/* Won-status wrap-up checklist — only renders when this interest's
outcome is `won`. Surfaces upload slots for the manual paperwork
that didn't flow through the EOI->Contract chain automatically. */}
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
{/* Pre-EOI supplemental info request. Sends the client a one-time
public form pre-filled with what's on file so they can confirm /
correct details before the EOI is drafted. Hides itself once
the EOI is signed. */}
<SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} />
<LinkedBerthsList interestId={interestId} />
{/* Berth recommender (plan §5.3) - always-mounted card driven by the
@@ -886,17 +982,19 @@ export function getInterestTabs({
// documents; if a deal regresses the past docs remain accessible
// via the generic Documents tab.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
// EOI: from details_sent through contract_signed (the deal's whole life)
const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx;
// Contract: appears once the deposit's been paid (deal is committed)
// and stays visible until the contract is signed
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx;
// Reservation: appears once the contract's signed and stays visible
// through completion (reservation is the post-contract milestone)
const showReservationTab = stageIdx >= contractSignedIdx;
const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const contractIdx = PIPELINE_STAGES.indexOf('contract');
// EOI: from qualified through contract (the deal's whole life past lead-only).
const showEoiTab = stageIdx >= qualifiedIdx;
// Reservation: once the EOI is signed onward — the reservation agreement
// is the v1 step between EOI and deposit. Stays visible through contract
// so the rep can re-open the signed reservation later.
const showReservationTab = stageIdx >= reservationIdx;
// Contract: from deposit_paid onward (deal is committed and the contract
// becomes the next active document).
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx;
const tabs: DetailTab[] = [
{

View File

@@ -114,8 +114,10 @@ function formatDimensions(
return parts.length > 0 ? parts.join(' · ') : null;
}
const SPECIFIC_CONSEQUENCE_ON = 'This berth will appear as under interest on the public map.';
const SPECIFIC_CONSEQUENCE_OFF = 'This berth is hidden from the public map.';
const SPECIFIC_CONSEQUENCE_ON =
'This berth will show as “Under Offer” on the public-facing marina map.';
const SPECIFIC_CONSEQUENCE_OFF =
'This berth stays marked “Available” on the public map — the link is internal only.';
// ─── Hooks ──────────────────────────────────────────────────────────────────
@@ -238,9 +240,19 @@ interface RowProps {
onUpdate: (berthId: string, patch: PatchPayload) => void;
onRemove: (berthId: string) => void;
isPending: boolean;
/** When true, this is the deal berth — render with elevated styling. */
highlight?: boolean;
}
function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPending }: RowProps) {
function LinkedBerthRowItem({
row,
portSlug,
eoiStatus,
onUpdate,
onRemove,
isPending,
highlight,
}: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
@@ -250,7 +262,7 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
<div
className={cn(
'rounded-lg border bg-card p-3 text-sm',
row.isPrimary ? 'border-brand-300 ring-1 ring-brand-200' : 'border-border',
highlight ? 'border-brand-300 ring-1 ring-brand-200 shadow-sm' : 'border-border',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
@@ -480,6 +492,30 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
const eoiStatus = data?.meta.eoiStatus ?? null;
const isPending = updateMutation.isPending || removeMutation.isPending;
// Three-bucket split per the Deal-berth + Bundle model:
// • dealBerth: the single is_primary row — the one templates/EOI
// resolve through ("the berth for this deal").
// • bundleRows: in EOI bundle but not primary.
// • exploringRows: everything else (also-considering, internal-only links).
// The same row never appears in two buckets — primary takes precedence,
// then bundle, then exploring.
const dealBerth = rows.find((r) => r.isPrimary) ?? null;
const bundleRows = rows.filter((r) => !r.isPrimary && r.isInEoiBundle);
const exploringRows = rows.filter((r) => !r.isPrimary && !r.isInEoiBundle);
const renderRow = (row: LinkedBerthRow, options?: { highlight?: boolean }) => (
<LinkedBerthRowItem
key={row.id}
row={row}
portSlug={portSlug}
eoiStatus={eoiStatus}
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
onRemove={(berthId) => removeMutation.mutate(berthId)}
isPending={isPending}
highlight={options?.highlight}
/>
);
return (
<Card>
<CardHeader>
@@ -488,7 +524,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
Linked berths{rows.length > 0 ? ` (${rows.length})` : ''}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-5">
{isLoading ? (
<div className="space-y-2">
{[0, 1].map((i) => (
@@ -500,19 +536,36 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
No berths linked yet. Use the recommender below to add one.
</p>
) : (
<div className="space-y-2">
{rows.map((row) => (
<LinkedBerthRowItem
key={row.id}
row={row}
portSlug={portSlug}
eoiStatus={eoiStatus}
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
onRemove={(berthId) => removeMutation.mutate(berthId)}
isPending={isPending}
/>
))}
</div>
<>
<BerthSection
title="Deal berth"
hint="The one berth this interest is anchored to — drives templates, the EOI primary slot, and the public-map status. Promote any other berth to take its place."
emptyText="No deal berth selected. Pick one of the linked berths below as the primary."
count={dealBerth ? 1 : 0}
>
{dealBerth ? renderRow(dealBerth, { highlight: true }) : null}
</BerthSection>
{bundleRows.length > 0 || dealBerth ? (
<BerthSection
title="In EOI bundle"
hint="Additional berths covered by the same EOI signature. Won't drive templates, but the client's signature applies to all of them."
count={bundleRows.length}
>
{bundleRows.map((row) => renderRow(row))}
</BerthSection>
) : null}
{exploringRows.length > 0 ? (
<BerthSection
title="Also considering"
hint="Linked for sales context (alternates the client glanced at, fallback options, etc.). No EOI coverage; toggle “In EOI bundle” to promote one here."
count={exploringRows.length}
>
{exploringRows.map((row) => renderRow(row))}
</BerthSection>
) : null}
</>
)}
{updateMutation.isError ? (
<p className="text-sm text-destructive">
@@ -528,3 +581,43 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
</Card>
);
}
/** Section header + body wrapper for the three-bucket layout. Kept inline
* because it's only used here — promoting it to /shared isn't worth the
* indirection for a card-header + a help line. */
function BerthSection({
title,
hint,
count,
emptyText,
children,
}: {
title: string;
hint: string;
count: number;
emptyText?: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-foreground">
{title}
{count > 0 ? (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">({count})</span>
) : null}
</h4>
</div>
<p className="text-[11px] text-muted-foreground">{hint}</p>
</div>
{count === 0 && emptyText ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{emptyText}
</p>
) : (
<div className="space-y-2">{children}</div>
)}
</section>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { FileSignature } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface DocumentRow {
id: string;
documentType: string;
status: string;
archivedAt: string | null;
}
interface DocumentsResponse {
data: DocumentRow[];
}
/**
* Subtle chip that surfaces when an interest has multiple in-flight EOI
* documents (status != voided, not archived). Per product direction we
* intentionally allow multi-EOI cases (sometimes a deal really does need
* a second EOI for a different berth combo), but the rep should see the
* conflict at a glance so they don't accidentally re-send.
*/
export function MultiEoiChip({ interestId }: { interestId: string }) {
const { data } = useQuery<DocumentsResponse>({
queryKey: ['documents', { interestId, documentType: 'eoi' }],
queryFn: () => apiFetch(`/api/v1/documents?interestId=${interestId}&documentType=eoi`),
staleTime: 60_000,
});
const inflight = (data?.data ?? []).filter(
(d) => !d.archivedAt && d.status !== 'voided' && d.status !== 'declined',
);
if (inflight.length < 2) return null;
return (
<span
title={`This interest has ${inflight.length} in-flight EOI documents — review on the EOI tab.`}
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
>
<FileSignature className="size-3" aria-hidden />
{inflight.length} EOIs
</span>
);
}

View File

@@ -0,0 +1,377 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Receipt } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface PaymentRow {
id: string;
paymentType: string;
amount: string;
currency: string;
receivedAt: string;
receiptFileId: string | null;
notes: string | null;
recordedBy: string;
createdAt: string;
}
interface PaymentsResponse {
data: {
payments: PaymentRow[];
depositTotal: { total: string; currency: string };
};
}
const TYPE_LABELS: Record<string, string> = {
deposit: 'Deposit',
balance: 'Balance',
refund: 'Refund',
other: 'Other',
};
const TYPE_TINT: Record<string, string> = {
deposit: 'bg-emerald-50 text-emerald-700 border-emerald-200',
balance: 'bg-sky-50 text-sky-700 border-sky-200',
refund: 'bg-rose-50 text-rose-700 border-rose-200',
other: 'bg-slate-100 text-slate-700 border-slate-200',
};
function formatMoney(amount: string, currency: string): string {
const n = Number(amount);
if (!Number.isFinite(n)) return `${amount} ${currency}`;
try {
return new Intl.NumberFormat('en-EU', { style: 'currency', currency }).format(n);
} catch {
return `${n.toFixed(2)} ${currency}`;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
export function PaymentsSection({
interestId,
depositExpectedAmount,
depositExpectedCurrency,
}: {
interestId: string;
depositExpectedAmount: string | null;
depositExpectedCurrency: string | null;
}) {
const queryClient = useQueryClient();
const [recordOpen, setRecordOpen] = useState(false);
const { data, isLoading } = useQuery<PaymentsResponse>({
queryKey: ['interest-payments', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/payments`),
});
const deleteMutation = useMutation({
mutationFn: async (paymentId: string) =>
apiFetch(`/api/v1/payments/${paymentId}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
},
onError: (err) => toastError(err),
});
if (isLoading) {
return (
<section className="rounded-lg border p-4 text-sm text-muted-foreground">
Loading payments
</section>
);
}
const payments = data?.data.payments ?? [];
const total = data?.data.depositTotal;
const expectedAmount = depositExpectedAmount ? Number(depositExpectedAmount) : null;
const expectedCurrency = depositExpectedCurrency ?? 'EUR';
const runningTotal = total ? Number(total.total) : 0;
const remaining =
expectedAmount !== null && Number.isFinite(expectedAmount)
? Math.max(0, expectedAmount - runningTotal)
: null;
return (
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">Payments</h3>
<p className="text-xs text-muted-foreground">
Records that money was received or refunded. No invoices are issued the bank handles
that.
</p>
</div>
<PermissionGate resource="invoices" action="record_payment">
<Button size="sm" className="h-8 px-3 text-xs" onClick={() => setRecordOpen(true)}>
<Plus className="size-3.5" aria-hidden />
Record payment
</Button>
</PermissionGate>
</div>
{expectedAmount !== null ? (
<div className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
<span>
Expected deposit:{' '}
<strong>{formatMoney(String(expectedAmount), expectedCurrency)}</strong>
</span>
<span>
Received so far: <strong>{formatMoney(total?.total ?? '0', expectedCurrency)}</strong>
</span>
{remaining !== null ? (
<span className={remaining === 0 ? 'text-emerald-700' : 'text-amber-700'}>
{remaining === 0
? 'Fully received'
: `${formatMoney(String(remaining), expectedCurrency)} outstanding`}
</span>
) : null}
</div>
) : null}
{payments.length === 0 ? (
<p className="rounded border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
No payments recorded yet.
</p>
) : (
<ul className="divide-y divide-border rounded border">
{payments.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex items-center gap-2.5">
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${
TYPE_TINT[p.paymentType] ?? TYPE_TINT.other
}`}
>
{TYPE_LABELS[p.paymentType] ?? p.paymentType}
</span>
<div className="text-sm">
<span className="font-medium">{formatMoney(p.amount, p.currency)}</span>
<span className="ml-2 text-xs text-muted-foreground">
{formatDate(p.receivedAt)}
</span>
{p.notes ? (
<span className="ml-2 text-xs text-muted-foreground">· {p.notes}</span>
) : null}
</div>
{p.receiptFileId ? (
<Receipt className="size-3 text-emerald-600" aria-hidden />
) : null}
</div>
<PermissionGate resource="invoices" action="record_payment">
<button
type="button"
aria-label="Delete payment record"
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm('Delete this payment record? This cannot be undone.')) {
deleteMutation.mutate(p.id);
}
}}
>
<Trash2 className="size-3.5" aria-hidden />
</button>
</PermissionGate>
</li>
))}
</ul>
)}
<RecordPaymentSheet
open={recordOpen}
onOpenChange={setRecordOpen}
interestId={interestId}
defaultCurrency={expectedCurrency}
/>
</section>
);
}
function RecordPaymentSheet({
open,
onOpenChange,
interestId,
defaultCurrency,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
interestId: string;
defaultCurrency: string;
}) {
const queryClient = useQueryClient();
const [paymentType, setPaymentType] = useState<string>('deposit');
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState(defaultCurrency);
const [receivedAt, setReceivedAt] = useState(() => {
const today = new Date();
return today.toISOString().slice(0, 10);
});
const [notes, setNotes] = useState('');
const [acknowledgedNoReceipt, setAcknowledgedNoReceipt] = useState(false);
const mutation = useMutation({
mutationFn: async () =>
apiFetch(`/api/v1/interests/${interestId}/payments`, {
method: 'POST',
body: {
interestId,
paymentType,
amount,
currency,
receivedAt: new Date(receivedAt).toISOString(),
notes: notes || null,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
onOpenChange(false);
// Reset form for next use
setAmount('');
setNotes('');
setAcknowledgedNoReceipt(false);
},
onError: (err) => toastError(err),
});
const canSubmit =
amount.trim().length > 0 && receivedAt && acknowledgedNoReceipt && !mutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>Record payment</SheetTitle>
<SheetDescription>
Capture that money was received (or refunded). Reps don&apos;t issue invoices the bank
does that so this is just an audit record.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 space-y-4"
onSubmit={(e) => {
e.preventDefault();
mutation.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="payment-type">Type</Label>
<Select value={paymentType} onValueChange={setPaymentType}>
<SelectTrigger id="payment-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deposit">Deposit</SelectItem>
<SelectItem value="balance">Balance</SelectItem>
<SelectItem value="refund">Refund</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-[1fr_100px] gap-2">
<div className="space-y-1.5">
<Label htmlFor="payment-amount">Amount</Label>
<Input
id="payment-amount"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="payment-currency">Currency</Label>
<Input
id="payment-currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="payment-date">Received on</Label>
<Input
id="payment-date"
type="date"
value={receivedAt}
onChange={(e) => setReceivedAt(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="payment-notes">Notes (optional)</Label>
<Input
id="payment-notes"
placeholder="Reference, payer name, etc."
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<label className="flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<input
type="checkbox"
checked={acknowledgedNoReceipt}
onChange={(e) => setAcknowledgedNoReceipt(e.target.checked)}
className="mt-0.5"
/>
<span>
I understand that recording a payment without an attached receipt may make later
verification harder, and that the bank-issued receipt is the canonical proof.
</span>
</label>
<SheetFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{mutation.isPending ? 'Saving…' : 'Record payment'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { CheckCircle2, ChevronRight } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface QualificationRow {
key: string;
label: string;
description: string | null;
enabled: boolean;
displayOrder: number;
confirmed: boolean;
confirmedAt: string | null;
confirmedBy: string | null;
notes: string | null;
}
interface QualificationResponse {
data: {
criteria: QualificationRow[];
fullyQualified: boolean;
};
}
/**
* Per-interest qualification checklist. Hidden when the port has no
* enabled criteria. When the rep has confirmed every enabled criterion AND
* the deal is still in 'enquiry', a soft hint surfaces a Promote button
* that advances the stage to 'qualified' through the standard transition
* endpoint (no override; this is the canonical adjacent move).
*/
export function QualificationChecklist({
interestId,
currentStage,
}: {
interestId: string;
currentStage: string;
}) {
const params = useParams<{ portSlug: string }>();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<QualificationResponse>({
queryKey: ['interest-qualifications', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/qualifications`),
});
const toggleMutation = useMutation({
mutationFn: async (vars: { criterionKey: string; confirmed: boolean }) =>
apiFetch(`/api/v1/interests/${interestId}/qualifications`, {
method: 'PUT',
body: { criterionKey: vars.criterionKey, confirmed: vars.confirmed },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interest-qualifications', interestId] });
},
onError: (err) => toastError(err),
});
const promoteMutation = useMutation({
mutationFn: async () =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'POST',
body: { pipelineStage: 'qualified' },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
},
onError: (err) => toastError(err),
});
if (isLoading) return null;
if (!data) return null;
const criteria = data.data.criteria;
if (criteria.length === 0) return null;
const fullyQualified = data.data.fullyQualified;
const showPromoteHint = fullyQualified && currentStage === 'enquiry';
// Avoid referencing `params` in the JSX so the unused destructure passes
// strict noUnused checks; it stays available for future deep-link hooks.
void params;
return (
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Qualification</h3>
{fullyQualified ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-700">
<CheckCircle2 className="size-3.5" aria-hidden />
All confirmed
</span>
) : (
<span className="text-xs text-muted-foreground">
{criteria.filter((c) => c.confirmed).length} of {criteria.length} confirmed
</span>
)}
</div>
<ul className="space-y-2">
{criteria.map((c) => (
<li key={c.key} className="flex items-start gap-2.5">
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
disabled={toggleMutation.isPending}
onCheckedChange={(v) =>
toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
}
className="mt-0.5"
/>
<label
htmlFor={`qual-${c.key}`}
className={cn(
'flex-1 text-sm cursor-pointer',
c.confirmed ? 'text-foreground' : 'text-foreground/90',
)}
>
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
</span>
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
) : null}
</label>
</li>
))}
</ul>
{showPromoteHint ? (
<div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2">
<p className="text-xs text-emerald-800">
All criteria confirmed this lead is ready to qualify.
</p>
<Button
type="button"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={promoteMutation.isPending}
onClick={() => promoteMutation.mutate()}
>
Promote to Qualified
<ChevronRight className="size-3.5" aria-hidden />
</Button>
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
interface SkipAheadInterest {
pipelineStage: string;
dateEoiSent?: string | null;
dateEoiSigned?: string | null;
dateReservationSigned?: string | null;
dateDepositReceived?: string | null;
dateContractSent?: string | null;
dateContractSigned?: string | null;
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
}
/**
* Soft banner that fires when a rep skip-advanced a deal past earlier
* milestones without backfilling the matching dates / doc-status badges.
*
* Why we care: the funnel/conversion analytics rely on these timestamps to
* compute how long deals sit in each stage. A deal that jumped straight to
* deposit_paid with no dateEoiSent looks like a 0-day-EOI in the report,
* which skews the cohort.
*
* The banner is informational only — no enforcement. Reps still have the
* override path; we just nudge them to fill in the gaps.
*/
export function SkipAheadBanner({ interest }: { interest: SkipAheadInterest }) {
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
if (stageIdx < 0) return null;
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const gaps: string[] = [];
// Past EOI but never stamped sent → likely a skip.
if (stageIdx > eoiIdx && !interest.dateEoiSent) gaps.push('EOI sent date');
if (stageIdx > eoiIdx && interest.eoiDocStatus !== 'signed' && !interest.dateEoiSigned) {
gaps.push('EOI signed date');
}
if (
stageIdx > reservationIdx &&
interest.reservationDocStatus !== 'signed' &&
!interest.dateReservationSigned
) {
gaps.push('Reservation signed date');
}
if (stageIdx > depositIdx && !interest.dateDepositReceived) {
gaps.push('Deposit received date');
}
if (gaps.length === 0) return null;
return (
<div
role="status"
className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"
>
<AlertCircle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
<div>
<p className="font-medium">
{gaps.length === 1
? 'A past milestone is missing its date.'
: `${gaps.length} past milestones are missing their dates.`}
</p>
<p className="mt-0.5 text-amber-800">
Backfill {gaps.join(' · ')} below so reports show accurate cycle times.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { ClipboardCopy, Mail } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { apiFetch } from '@/lib/api/client';
interface Props {
interestId: string;
/** Hide the button when EOI has already been sent / signed — at that
* point the supplemental step is past its window. Caller passes the
* current eoiStatus so we can render contextually. */
eoiStatus?: string | null;
}
interface IssueResponse {
data: {
link: string;
expiresAt: string;
emailSent: boolean;
};
}
/**
* One-click "Request more info" action. Fires the supplemental-info-
* request endpoint, which emails the client a public form pre-filled
* with what's on file. On success we display the generated link + a
* copy-to-clipboard button in case the rep needs to share it through
* another channel.
*
* Hidden once the EOI is `signed` — the supplemental step only makes
* sense before the signed EOI freezes the data into the contract path.
*/
export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) {
const [link, setLink] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
apiFetch<IssueResponse>(`/api/v1/interests/${interestId}/supplemental-info-request`, {
method: 'POST',
}),
onSuccess: (res) => {
setLink(res.data.link);
if (res.data.emailSent) {
toast.success('Email sent — link also shown below for sharing manually.');
} else {
toast.message('Link generated — no client email on file, share manually.');
}
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Failed to generate the form link.'),
});
if (eoiStatus === 'signed') return null;
return (
<Card>
<CardContent className="space-y-3 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Need more info before drafting the EOI?</h3>
<p className="text-xs text-muted-foreground">
Email the client a one-time link to a public form pre-filled with what we have on file.
Submissions auto-update this client + interest record.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Mail className="mr-1.5 size-3.5" aria-hidden />
{mutation.isPending ? 'Generating…' : link ? 'Resend' : 'Request more info'}
</Button>
{link ? (
<>
<Input value={link} readOnly className="h-8 text-xs font-mono flex-1 min-w-[260px]" />
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard.writeText(link);
toast.success('Link copied');
}}
>
<ClipboardCopy className="mr-1.5 size-3.5" aria-hidden />
Copy
</Button>
</>
) : null}
</div>
</CardContent>
</Card>
);
}

View File

@@ -11,7 +11,9 @@ const SILENT_DAYS_THRESHOLD = 7;
const EOI_AWAITING_DAYS_THRESHOLD = 14;
const DEPOSIT_PENDING_DAYS_THRESHOLD = 21;
const ACTIVE_MID_FUNNEL_STAGES = new Set(['details_sent', 'in_communication']);
// Mid-funnel = post-enquiry, pre-EOI. Surfaces the silent-deal warning so
// reps notice deals stuck in qualifying/nurturing without recent contact.
const ACTIVE_MID_FUNNEL_STAGES = new Set(['qualified', 'nurturing']);
export interface InterestUrgencyInput {
pipelineStage: string;
@@ -74,8 +76,11 @@ export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[]
}
}
// EOI signed but deposit not received.
if (row.pipelineStage === 'eoi_signed' && !row.dateDepositReceived && row.dateEoiSent) {
// EOI signed (or further along) but deposit not received yet. The deposit
// is its own stage now; we trigger the warning while the deal is past EOI
// signing but hasn't reached deposit_paid + has no dateDepositReceived.
const eoiOrPast = row.pipelineStage === 'eoi' || row.pipelineStage === 'reservation';
if (eoiOrPast && !row.dateDepositReceived && row.dateEoiSent) {
const days = daysSince(row.dateEoiSent);
if (days !== null && days >= DEPOSIT_PENDING_DAYS_THRESHOLD) {
badges.push({

View File

@@ -0,0 +1,211 @@
'use client';
import { useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, FileUp, Trophy, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
/**
* Won-status manual-upload panel.
*
* Renders only when the interest's outcome is `won`. Detects whether
* a Contract / EOI / Other doc is already attached (via the existing
* files endpoint) and shows an "Upload {missing}" slot for each one
* that isn't there yet. Naturally hides itself once the rep has filed
* everything.
*
* Reps that reached Won through the natural EOI → Contract → Deposit
* chain will typically see all three slots already filled and the
* panel collapses to a green confirmation strip.
*/
interface WonStatusPanelProps {
interestId: string;
/** Outcome of the interest. Panel hides unless 'won'. */
outcome: string | null;
}
interface FileRow {
id: string;
category: string | null;
filename: string;
originalName: string | null;
}
interface FilesResponse {
data: FileRow[];
}
interface UploadSlot {
key: 'contract' | 'eoi' | 'other';
label: string;
description: string;
category: string;
}
const SLOTS: UploadSlot[] = [
{
key: 'contract',
label: 'Signed contract',
description: 'Final purchase / lease agreement signed by both sides.',
category: 'contract',
},
{
key: 'eoi',
label: 'Signed EOI',
description: "Required only if you didn't run the EOI through the in-app signing flow.",
category: 'eoi',
},
{
key: 'other',
label: 'Other supporting docs',
description: 'Insurance certificates, ID, anything else worth attaching to the closed deal.',
category: 'other',
},
];
export function WonStatusPanel({ interestId, outcome }: WonStatusPanelProps) {
const qc = useQueryClient();
// Fetch the files attached to this interest so we can hide slots that
// are already filled. The endpoint accepts `entityType` + `entityId`
// for polymorphic ownership; non-interest files are filtered out.
const { data } = useQuery<FilesResponse>({
queryKey: ['interest-files', interestId],
queryFn: () => apiFetch(`/api/v1/files?entityType=interest&entityId=${interestId}&limit=100`),
enabled: outcome === 'won',
staleTime: 30_000,
});
const existing = data?.data ?? [];
if (outcome !== 'won') return null;
const slots = SLOTS.map((s) => ({
...s,
files: existing.filter((f) => (f.category ?? '').toLowerCase() === s.category),
}));
const allFilled = slots[0]!.files.length > 0 && slots[1]!.files.length > 0;
return (
<Card className="border-emerald-200 bg-emerald-50/40">
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-base text-emerald-900">
<Trophy className="size-4" aria-hidden />
Won wrap-up checklist
</CardTitle>
<p className="text-xs text-emerald-800/80">
Upload anything that didn&apos;t flow through the system automatically. Reservations,
deposit invoicing, and client billing are handled outside the CRM this checklist is for
the paperwork that lives on the deal itself.
</p>
</CardHeader>
<CardContent className="space-y-2">
{slots.map((s) => (
<UploadSlotRow
key={s.key}
slot={s}
interestId={interestId}
onUploaded={() => qc.invalidateQueries({ queryKey: ['interest-files', interestId] })}
/>
))}
{allFilled ? (
<p className="pt-1 text-xs text-emerald-800/80 italic">
All required documents are attached. Anything else you upload here will appear in the
client&apos;s signed-docs folder.
</p>
) : null}
</CardContent>
</Card>
);
}
function UploadSlotRow({
slot,
interestId,
onUploaded,
}: {
slot: UploadSlot & { files: FileRow[] };
interestId: string;
onUploaded: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const filled = slot.files.length > 0;
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append('file', file);
fd.append('entityType', 'interest');
fd.append('entityId', interestId);
fd.append('category', slot.category);
const res = await fetch('/api/v1/files/upload', { method: 'POST', body: fd });
if (!res.ok) {
const payload = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
throw new Error(payload.error?.message ?? `Upload failed (${res.status})`);
}
},
onMutate: () => setUploading(true),
onSuccess: () => {
toast.success(`${slot.label} uploaded`);
onUploaded();
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Upload failed'),
onSettled: () => setUploading(false),
});
return (
<div
className={cn(
'rounded-md border p-3 text-sm',
filled ? 'border-emerald-300 bg-white' : 'border-input bg-card',
)}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 font-medium">
{filled ? <CheckCircle2 className="size-4 text-emerald-600" aria-hidden /> : null}
{slot.label}
{filled ? (
<span className="text-xs font-normal text-muted-foreground">
({slot.files.length} on file)
</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">{slot.description}</p>
</div>
<Button
type="button"
size="sm"
variant={filled ? 'outline' : 'default'}
onClick={() => inputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" aria-hidden />
) : (
<FileUp className="mr-1.5 size-3.5" aria-hidden />
)}
{filled ? 'Add another' : 'Upload'}
</Button>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
e.target.value = '';
}}
/>
</div>
</div>
);
}

View File

@@ -363,7 +363,7 @@ function SidebarContent({
</ScrollArea>
{/* User footer - entire row is the trigger for the UserMenu so the
user can click their name/avatar to access Profile / Settings /
user can click their name/avatar to access Settings /
port-switcher / sign-out. The same UserMenu component drives the
top-right avatar dropdown, so the menu items stay consistent. */}
<div className={cn('border-t border-slate-200 p-2', collapsed && 'flex justify-center')}>

View File

@@ -160,28 +160,36 @@ function ReminderFormBody({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
<SheetTitle>{isEdit ? 'Edit reminder' : 'New reminder'}</SheetTitle>
<p className="text-sm text-muted-foreground mt-2">
Reminders are personal nudges a follow-up call, a note to yourself, or something a
teammate needs to action by a date. They show up in your dashboard, the daily digest
email, and on whichever client / interest / berth you link them to.
</p>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="reminder-title">Title</Label>
<Label htmlFor="reminder-title">What&apos;s the reminder for?</Label>
<Input
id="reminder-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Follow up with client..."
placeholder="e.g. Follow up about EOI, Check insurance docs"
required
/>
<p className="text-[11px] text-muted-foreground">
Short label so future-you knows what this is at a glance.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="reminder-note">Note</Label>
<Label htmlFor="reminder-note">Note (optional)</Label>
<Textarea
id="reminder-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Additional details..."
placeholder="Anything else you want to remember when this fires…"
rows={3}
/>
</div>
@@ -241,10 +249,13 @@ function ReminderFormBody({
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Link to entity (optional)</Label>
<Label className="text-xs text-muted-foreground">Attach to client / deal / berth</Label>
<p className="text-[11px] text-muted-foreground">
Pick a client first to scope the interest and berth dropdowns to that client&apos;s
deals.
Linking a reminder pins it onto that record so anyone who opens the page sees it on
the Reminders tab. Useful for &ldquo;chase this client for signed EOI&rdquo;,
&ldquo;recheck B12 power capacity before contract&rdquo;, etc. Pick a client first to
scope the interest and berth dropdowns to that client&apos;s deals. Leaving these
blank keeps the reminder private to you on your dashboard only.
</p>
<div className="grid grid-cols-1 gap-2">
<ClientPicker

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -158,10 +159,11 @@ export function ResidentialClientsList() {
target="_blank"
rel="noreferrer"
title="WhatsApp"
aria-label="Message on WhatsApp"
className="text-emerald-600 hover:text-emerald-700"
onClick={(e) => e.stopPropagation()}
>
WA
<WhatsAppIcon className="h-3.5 w-3.5" />
</a>
</span>
) : (

View File

@@ -875,13 +875,19 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
if (include('clients')) {
for (const c of results.clients) {
// Prefer the actual matched-contact value (real email/phone the
// query hit) over a generic "matched on X" label. Falls back to
// the typed matchedOn when no value is available (e.g. trigram
// similarity match on the name itself).
let sub: string | null = c.matchedContact ?? null;
if (!sub && c.matchedOn) sub = `matched on ${c.matchedOn}`;
rows.push({
kind: 'result',
key: `clients:${c.id}`,
bucket: 'clients',
icon: User,
label: c.fullName,
sub: c.matchedContact ?? null,
sub,
href: `/${portSlug}/clients/${c.id}`,
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
relatedVia: c.relatedVia ?? null,

View File

@@ -25,6 +25,30 @@ interface BerthOption {
status: string;
}
/**
* Group berth options by area letter extracted from the canonical mooring
* format `^[A-Z]+\d+$` (A1, B12, etc). Falls back to a single bucket
* keyed by empty string when no letter is present so callers still see
* every row. Sorts by area letter then natural-numeric within each group
* so A1, A2, A10 reads in human order rather than lexicographic.
*/
export function groupOptionsByArea(options: BerthOption[]): [string, BerthOption[]][] {
const map = new Map<string, BerthOption[]>();
for (const o of options) {
const m = o.mooringNumber.match(/^([A-Z]+)/);
const key = m?.[1] ?? '';
const bucket = map.get(key) ?? [];
bucket.push(o);
map.set(key, bucket);
}
// Natural sort within bucket: split letter prefix from number suffix.
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
for (const bucket of map.values()) {
bucket.sort((a, b) => collator.compare(a.mooringNumber, b.mooringNumber));
}
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
}
interface BerthPickerProps {
value: string | null;
onChange: (berthId: string | null) => void;
@@ -117,6 +141,9 @@ export function BerthPicker({
const labelFor = (o: BerthOption) =>
o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`;
// Group helper outside render so memoization works; takes/returns plain
// values so the same logic plugs into linked-berths and recommender pickers later.
const selectedLabel = (() => {
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
@@ -150,8 +177,8 @@ export function BerthPicker({
<CommandEmpty>
{clientId ? 'No berths linked to this client.' : 'No berths found.'}
</CommandEmpty>
<CommandGroup>
{value ? (
{value ? (
<CommandGroup>
<CommandItem
value="__clear__"
onSelect={() => {
@@ -162,23 +189,27 @@ export function BerthPicker({
>
Clear selection
</CommandItem>
) : null}
{options.map((o) => (
<CommandItem
key={o.id}
value={o.id}
onSelect={() => {
onChange(o.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{labelFor(o)}</span>
</CommandItem>
))}
</CommandGroup>
</CommandGroup>
) : null}
{groupOptionsByArea(options).map(([letter, group]) => (
<CommandGroup key={letter || '_'} heading={letter || 'Other'}>
{group.map((o) => (
<CommandItem
key={o.id}
value={o.id}
onSelect={() => {
onChange(o.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{labelFor(o)}</span>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>

View File

@@ -15,8 +15,20 @@ import {
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { CurrencySelect } from '@/components/shared/currency-select';
import type { CountryCode } from '@/lib/i18n/countries';
export type FilterType = 'text' | 'select' | 'multi-select' | 'date-range' | 'boolean' | 'relation';
export type FilterType =
| 'text'
| 'select'
| 'multi-select'
| 'date-range'
| 'date'
| 'boolean'
| 'relation'
| 'currency'
| 'country';
export interface FilterOption {
label: string;
@@ -256,6 +268,43 @@ function FilterField({
);
}
case 'date':
return (
<div className="space-y-1">
<Label className="text-xs">{definition.label}</Label>
<Input
type="date"
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
className="h-8"
/>
</div>
);
case 'currency':
return (
<div className="space-y-1">
<Label className="text-xs">{definition.label}</Label>
<CurrencySelect
value={(value as string) ?? undefined}
onValueChange={(v) => onChange(v || undefined)}
className="h-8"
/>
</div>
);
case 'country':
return (
<div className="space-y-1">
<Label className="text-xs">{definition.label}</Label>
<CountryCombobox
value={(value as CountryCode | null) ?? null}
onChange={(c) => onChange(c ?? undefined)}
clearable
/>
</div>
);
case 'boolean':
return (
<div className="flex items-center gap-2">

View File

@@ -24,6 +24,13 @@ export interface InlineTagEditorProps {
invalidateKey: readonly unknown[];
/** Hide the "+ Add tag" button (read-only mode). */
readOnly?: boolean;
/** Optional section heading rendered above the chips. When supplied and
* there are no tags configured port-wide AND none currently applied,
* the entire block (heading + editor) hides — keeps detail pages clean
* for ports that haven't set up tagging. */
heading?: string;
/** Optional wrapper class applied around heading + editor. */
wrapperClassName?: string;
}
export function InlineTagEditor({
@@ -31,15 +38,20 @@ export function InlineTagEditor({
currentTags,
invalidateKey,
readOnly,
heading,
wrapperClassName,
}: InlineTagEditorProps) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
// Always fetch so we can hide the editor entirely when no tags are
// configured AND the entity has no tags already applied — keeps the
// detail page clean for ports that haven't set up tagging yet. The
// list is cheap, port-scoped, and cached for a minute.
const { data: allTags } = useQuery<{ data: Tag[] }>({
queryKey: ['tags'],
queryFn: () => apiFetch('/api/v1/tags'),
staleTime: 60_000,
enabled: open,
});
const setTags = useMutation({
@@ -60,7 +72,15 @@ export function InlineTagEditor({
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
}
return (
// Hide the whole editor when the port has no tags configured AND this
// entity has none applied. Once an admin adds the first tag in
// Admin → Tags, the editor reappears on next mount/refetch.
const portHasNoTags = allTags && allTags.data.length === 0;
if (portHasNoTags && currentTags.length === 0) {
return null;
}
const editor = (
<div className="flex flex-wrap items-center gap-1.5">
{currentTags.map((t) => (
<span
@@ -129,4 +149,13 @@ export function InlineTagEditor({
)}
</div>
);
if (!heading) return editor;
return (
<div className={cn('space-y-1', wrapperClassName)}>
<h3 className="text-sm font-medium mb-2">{heading}</h3>
{editor}
</div>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface UserOption {
id: string;
displayName: string | null;
}
/**
* Picker over the current port's users. Stores either a user ID (when
* a user is selected) or a plain string (when "Other..." is chosen and
* a custom name is typed). Callers pass `value` as a plain string and
* the picker maps it back to a user when one matches the id.
*
* Used by the expense form where the payer can be either a staff member
* or an external party (vendor employee paying the bill, etc.).
*/
export function UserPicker({
value,
onChange,
placeholder = 'Select user…',
disabled,
className,
}: {
value: string | null | undefined;
onChange: (next: string | null) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [otherMode, setOtherMode] = useState(false);
const { data } = useQuery<{ data: UserOption[] }>({
queryKey: ['user-options'],
queryFn: () => apiFetch('/api/v1/admin/users/options'),
staleTime: 5 * 60_000,
// Don't fetch until the popover opens — keeps the page light when
// most reps never expand this field.
enabled: open,
});
const users = data?.data ?? [];
const matched = value ? users.find((u) => u.id === value) : null;
// When the stored value isn't one of the fetched users' ids, treat it
// as a free-text payer name (the "Other..." path).
const displayLabel = (() => {
if (!value) return placeholder;
if (matched) return matched.displayName ?? matched.id.slice(0, 8);
return value;
})();
if (otherMode) {
return (
<div className={cn('flex gap-2', className)}>
<Input
autoFocus
placeholder="Custom payer name"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setOtherMode(false);
onChange(null);
}}
>
Pick user
</Button>
</div>
);
}
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground', className)}
>
<span className="truncate">{displayLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="Search users…" />
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup>
{users.map((u) => (
<CommandItem
key={u.id}
value={u.displayName ?? u.id}
onSelect={() => {
onChange(u.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === u.id ? 'opacity-100' : 'opacity-0')}
/>
{u.displayName ?? u.id.slice(0, 8)}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Or">
<CommandItem
value="__other__"
onSelect={() => {
setOpen(false);
setOtherMode(true);
onChange(null);
}}
>
Other
</CommandItem>
{value && !matched ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear (currently: {value})
</CommandItem>
) : null}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -36,7 +36,9 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
// Centered fade + subtle zoom-in (no slide-from-corner — drops
// the jarring fly-from-top-left effect the Dialog primitive had).
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className,
)}
{...props}

View File

@@ -25,7 +25,11 @@ const PopoverContent = React.forwardRef<
// on narrow viewports the calc() ceiling kicks in.
collisionPadding={collisionPadding}
className={cn(
'z-50 w-[min(calc(100vw-2rem),18rem)] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)',
// max-h pinned to Radix's available-height var so tall popovers
// (multi-field filter panels, long picker lists) scroll inside
// the popover instead of overflowing below the viewport. The
// overflow-y-auto pairs with it so scroll actually engages.
'z-50 max-h-[var(--radix-popover-content-available-height)] w-[min(calc(100vw-2rem),18rem)] overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)',
className,
)}
{...props}

View File

@@ -79,6 +79,7 @@ export function YachtList() {
setPageSize,
filters,
setFilter,
setAllFilters,
clearFilters,
} = usePaginatedQuery<YachtRow>({
queryKey: ['yachts'],
@@ -133,8 +134,7 @@ export function YachtList() {
<SavedViewsDropdown
entityType="yachts"
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
setAllFilters(savedFilters);
}}
/>
</div>

View File

@@ -234,15 +234,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
/>
</div>
{/* Tags */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
</div>
<InlineTagEditor
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
</div>
);
}

View File

@@ -109,6 +109,20 @@ export function usePaginatedQuery<T>({
syncUrl(1, pageSize, sort, {});
}
/**
* Atomically replace the entire filter set. Used by the saved-views
* apply path — calling `clearFilters()` + N x `setFilter()` in a row
* lost all but the last setFilter because each one reads the stale
* `filters` closure and overwrites with `{...filters, key: val}`.
* setAllFilters writes the whole object in one setState so the view
* lands intact.
*/
function setAllFilters(next: FilterValues) {
setFiltersState(next);
setPageState(1);
syncUrl(1, pageSize, sort, next);
}
// Build query string for API
const apiParams = useMemo(() => {
const params = new URLSearchParams();
@@ -174,6 +188,7 @@ export function usePaginatedQuery<T>({
setPageSize,
filters,
setFilter,
setAllFilters,
clearFilters,
optimisticRemove,
};

View File

@@ -43,6 +43,7 @@ export interface ClientResult {
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
matchedOn?: string | null;
}
export interface ResidentialClientResult {
id: string;

View File

@@ -0,0 +1,153 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Web Speech API wrapper. Browser-only — gracefully reports `supported: false`
* when SpeechRecognition isn't available (Firefox, Safari < 14.1, server-side
* render).
*
* Surfaces both the interim (still-being-spoken) and final (committed)
* transcripts so the UI can show "real-time" feedback while typing.
*
* Usage:
* const { supported, isListening, transcript, interim, start, stop, reset } =
* useVoiceTranscription();
*
* The summary form appends `transcript` (final committed text) to the textarea
* and separately persists it to `voiceTranscript` on the server. The rep can
* still edit the summary freely — the raw transcript stays untouched.
*/
interface SpeechRecognitionEventLike {
resultIndex: number;
results: ArrayLike<{
isFinal: boolean;
0: { transcript: string };
}>;
}
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
abort: () => void;
}
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
if (typeof window === 'undefined') return null;
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
export interface VoiceTranscriptionApi {
/** True when the browser exposes Web Speech (or a vendor-prefixed equivalent). */
supported: boolean;
/** Currently capturing audio. */
isListening: boolean;
/** Final, committed transcript accumulated across the current session. */
transcript: string;
/** Latest interim (still being recognized) transcript fragment. */
interim: string;
/** Set when the underlying API returns an error (permission denied, no mic, etc.). */
error: string | null;
start: () => void;
stop: () => void;
reset: () => void;
}
export function useVoiceTranscription(opts?: { lang?: string }): VoiceTranscriptionApi {
// SSR-safe: getSpeechRecognitionCtor() returns null on the server. We want
// the support flag to be stable across renders rather than flipping inside
// an effect (set-state-in-effect lint), so derive it from the constructor
// lookup at render time — useState's lazy initializer runs once per mount.
const [supported] = useState(() => getSpeechRecognitionCtor() !== null);
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
useEffect(() => {
const Ctor = getSpeechRecognitionCtor();
if (!Ctor) return;
const recognition = new Ctor();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = opts?.lang ?? 'en-US';
recognition.onresult = (event) => {
let interimText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const res = event.results[i];
if (!res) continue;
const chunk = res[0].transcript;
if (res.isFinal) {
setTranscript((prev) => (prev ? `${prev} ${chunk}`.replace(/ {2,}/g, ' ') : chunk));
} else {
interimText += chunk;
}
}
setInterim(interimText);
};
recognition.onerror = (event) => {
setError(event.error);
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
setInterim('');
};
recognitionRef.current = recognition;
return () => {
try {
recognition.abort();
} catch {
// ignore — already-stopped recognizers throw on abort()
}
recognitionRef.current = null;
};
}, [opts?.lang]);
const start = useCallback(() => {
if (!recognitionRef.current || isListening) return;
setError(null);
try {
recognitionRef.current.start();
setIsListening(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start voice capture');
}
}, [isListening]);
const stop = useCallback(() => {
if (!recognitionRef.current || !isListening) return;
try {
recognitionRef.current.stop();
} catch {
// ignore
}
}, [isListening]);
const reset = useCallback(() => {
setTranscript('');
setInterim('');
setError(null);
}, []);
return { supported, isListening, transcript, interim, error, start, stop, reset };
}

View File

@@ -46,7 +46,12 @@ export type AuditAction =
| 'webhook_dead_letter'
| 'webhook_retried'
| 'job_failed'
| 'cron_run';
| 'cron_run'
// Berth-rule decision trace: emitted by the rules engine on every
// evaluateRule() call so admins can debug "why did this fire / not fire"
// without reading server logs. Distinct from the actual `update` audit
// row the auto-applied path emits when it mutates berth status.
| 'rule_evaluated';
/**
* Common shape passed to service functions so they can stamp audit logs and

View File

@@ -1,97 +1,102 @@
// ─── Pipeline Stages ─────────────────────────────────────────────────────────
//
// 7 canonical stages (one optional). Document-signing stages (EOI, Reservation,
// Contract) collapse "Sent + Signed" into one stage; the sub-status lives on
// per-stage doc-status columns (`eoi_doc_status`, etc.) and is rendered as a
// badge inside the kanban card.
//
// `nurturing` is built but disabled-by-default for ports that don't have
// supply constraints (e.g. Port Nimara pre-launch). Admins enable it per port.
export const PIPELINE_STAGES = [
'open',
'details_sent',
'in_communication',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
'enquiry',
'qualified',
'nurturing',
'eoi',
'reservation',
'deposit_paid',
'contract',
] as const;
export type PipelineStage = (typeof PIPELINE_STAGES)[number];
/**
* Sub-status values for document-signing stages (EOI, Reservation, Contract).
* Stored on per-stage columns `eoi_doc_status` / `reservation_doc_status` /
* `contract_doc_status` on the interests table.
*/
export const DOC_STATUSES = ['pending', 'sent', 'signed', 'declined', 'voided'] as const;
export type DocStatus = (typeof DOC_STATUSES)[number];
export const STAGE_LABELS: Record<PipelineStage, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Comms',
eoi_sent: 'EOI Sent',
eoi_signed: 'EOI Signed',
deposit_10pct: 'Deposit 10%',
contract_sent: 'Contract Sent',
contract_signed: 'Contract Signed',
completed: 'Completed',
enquiry: 'New Enquiry',
qualified: 'Qualified',
nurturing: 'Nurturing',
eoi: 'EOI',
reservation: 'Reservation',
deposit_paid: 'Deposit Paid',
contract: 'Contract',
};
// Compact labels for cramped contexts (mobile chart axes, dense tables).
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
open: 'Open',
details_sent: 'Details',
in_communication: 'Comms',
eoi_sent: 'EOI',
eoi_signed: 'EOI ✓',
deposit_10pct: 'Dep.',
contract_sent: 'Ctr →',
contract_signed: 'Ctr ✓',
completed: 'Done',
enquiry: 'Enquiry',
qualified: 'Qual.',
nurturing: 'Nurt.',
eoi: 'EOI',
reservation: 'Resv.',
deposit_paid: 'Dep.',
contract: 'Contract',
};
export const STAGE_BADGE: Record<PipelineStage, string> = {
open: 'bg-slate-100 text-slate-700',
details_sent: 'bg-blue-100 text-blue-700',
in_communication: 'bg-sky-100 text-sky-700',
eoi_sent: 'bg-indigo-100 text-indigo-700',
eoi_signed: 'bg-amber-100 text-amber-700',
deposit_10pct: 'bg-orange-100 text-orange-700',
contract_sent: 'bg-yellow-100 text-yellow-700',
contract_signed: 'bg-green-100 text-green-700',
completed: 'bg-emerald-100 text-emerald-700',
enquiry: 'bg-slate-100 text-slate-700',
qualified: 'bg-blue-100 text-blue-700',
nurturing: 'bg-purple-100 text-purple-700',
eoi: 'bg-indigo-100 text-indigo-700',
reservation: 'bg-amber-100 text-amber-700',
deposit_paid: 'bg-orange-100 text-orange-700',
contract: 'bg-green-100 text-green-700',
};
export const STAGE_DOT: Record<PipelineStage, string> = {
open: 'bg-slate-400',
details_sent: 'bg-blue-500',
in_communication: 'bg-sky-500',
eoi_sent: 'bg-indigo-500',
eoi_signed: 'bg-amber-500',
deposit_10pct: 'bg-orange-500',
contract_sent: 'bg-yellow-500',
contract_signed: 'bg-green-500',
completed: 'bg-emerald-500',
enquiry: 'bg-slate-400',
qualified: 'bg-blue-500',
nurturing: 'bg-purple-500',
eoi: 'bg-indigo-500',
reservation: 'bg-amber-500',
deposit_paid: 'bg-orange-500',
contract: 'bg-green-500',
};
// Default revenue-forecast probability weights per stage (01).
// Editable per port via settings (`pipeline_weights`); these are the fallbacks.
export const STAGE_WEIGHTS: Record<PipelineStage, number> = {
open: 0.05,
details_sent: 0.1,
in_communication: 0.2,
eoi_sent: 0.4,
eoi_signed: 0.6,
deposit_10pct: 0.75,
contract_sent: 0.85,
contract_signed: 0.95,
completed: 1.0,
enquiry: 0.05,
qualified: 0.15,
nurturing: 0.15,
eoi: 0.4,
reservation: 0.7,
deposit_paid: 0.85,
contract: 0.95,
};
// Allowed transitions out of each stage. Used by changeInterestStage to guard
// against accidental skips (e.g. dragging a card from Completed back to Open,
// or jumping Open straight to Completed). Forward moves of 1-2 stages are
// permitted; backward moves are limited to the immediate predecessor unless
// the lifecycle (EOI/contract chain) needs an explicit rewind.
/**
* Allowed transitions out of each stage. Skip-aheads (e.g. enquiry →
* deposit_paid) are gated by the explicit `override:true` path in
* `changeInterestStage` and surface as a backfill banner on the interest.
*
* Nurturing is bidirectional with qualified (deal pauses → reopens),
* and can re-enter the EOI path when supply opens up.
*/
export const STAGE_TRANSITIONS: Record<PipelineStage, readonly PipelineStage[]> = {
open: ['details_sent', 'in_communication', 'eoi_sent', 'eoi_signed'],
details_sent: ['open', 'in_communication', 'eoi_sent', 'eoi_signed'],
in_communication: ['open', 'details_sent', 'eoi_sent', 'eoi_signed'],
eoi_sent: ['in_communication', 'eoi_signed', 'deposit_10pct'],
eoi_signed: ['eoi_sent', 'deposit_10pct', 'contract_sent', 'contract_signed'],
deposit_10pct: ['eoi_signed', 'contract_sent', 'contract_signed'],
contract_sent: ['eoi_signed', 'deposit_10pct', 'contract_signed'],
contract_signed: ['contract_sent', 'deposit_10pct', 'completed'],
completed: ['contract_signed'],
enquiry: ['qualified', 'eoi'],
qualified: ['enquiry', 'nurturing', 'eoi'],
nurturing: ['qualified', 'eoi'],
eoi: ['qualified', 'reservation', 'deposit_paid'],
reservation: ['eoi', 'deposit_paid'],
deposit_paid: ['reservation', 'contract'],
contract: ['deposit_paid'],
};
export function canTransitionStage(from: string, to: string): boolean {
@@ -102,7 +107,7 @@ export function canTransitionStage(from: string, to: string): boolean {
}
export function safeStage(value: string | null | undefined): PipelineStage {
return PIPELINE_STAGES.includes(value as PipelineStage) ? (value as PipelineStage) : 'open';
return PIPELINE_STAGES.includes(value as PipelineStage) ? (value as PipelineStage) : 'enquiry';
}
export function stageLabel(stage: string | null | undefined): string {

View File

@@ -0,0 +1,139 @@
-- 0062_pipeline_refactor.sql
-- ----------------------------------------------------------------------------
-- Pipeline refactor: 7 canonical stages, doc sub-status columns, qualification
-- criteria, payment records, assigned_to ownership, expected deposit amount.
-- Dummy-data only at this point; legacy-to-new migration for prod cutover is
-- a separate one-shot tool to be written when production data is ready.
-- ─── interests: new columns ────────────────────────────────────────────────
ALTER TABLE interests
ADD COLUMN IF NOT EXISTS assigned_to text REFERENCES user_profiles(user_id),
ADD COLUMN IF NOT EXISTS deposit_expected_amount numeric,
ADD COLUMN IF NOT EXISTS deposit_expected_currency text DEFAULT 'EUR',
ADD COLUMN IF NOT EXISTS eoi_doc_status text,
ADD COLUMN IF NOT EXISTS reservation_doc_status text,
ADD COLUMN IF NOT EXISTS contract_doc_status text,
ADD COLUMN IF NOT EXISTS reservation_documenso_id text,
ADD COLUMN IF NOT EXISTS contract_documenso_id text,
ADD COLUMN IF NOT EXISTS date_reservation_signed timestamptz;
CREATE INDEX IF NOT EXISTS idx_interests_assigned_to ON interests (assigned_to);
-- ─── stage value migration (collapse Sent/Signed pairs) ────────────────────
-- Dummy-data only — destructive UPDATE is safe.
UPDATE interests SET pipeline_stage = 'enquiry'
WHERE pipeline_stage IN ('open', 'details_sent', 'in_communication');
UPDATE interests
SET pipeline_stage = 'eoi', eoi_doc_status = 'sent'
WHERE pipeline_stage = 'eoi_sent';
UPDATE interests
SET pipeline_stage = 'eoi', eoi_doc_status = 'signed'
WHERE pipeline_stage = 'eoi_signed';
UPDATE interests SET pipeline_stage = 'deposit_paid'
WHERE pipeline_stage = 'deposit_10pct';
UPDATE interests
SET pipeline_stage = 'contract', contract_doc_status = 'sent'
WHERE pipeline_stage = 'contract_sent';
UPDATE interests
SET pipeline_stage = 'contract', contract_doc_status = 'signed'
WHERE pipeline_stage = 'contract_signed';
-- `completed` collapses into contract+signed+won (the old terminal stage
-- always implied outcome=won; outcome field carries that forward).
UPDATE interests
SET pipeline_stage = 'contract',
contract_doc_status = 'signed',
outcome = COALESCE(outcome, 'won'),
outcome_at = COALESCE(outcome_at, updated_at)
WHERE pipeline_stage = 'completed';
-- ─── Qualification criteria (per-port, admin-configurable) ──────────────────
CREATE TABLE IF NOT EXISTS qualification_criteria (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
key text NOT NULL,
label text NOT NULL,
description text,
enabled boolean NOT NULL DEFAULT true,
display_order int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (port_id, key)
);
CREATE INDEX IF NOT EXISTS idx_qualification_criteria_port
ON qualification_criteria (port_id);
-- Seed the default criteria for every existing port. Port admins can later
-- enable/disable or add port-specific entries.
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
SELECT p.id, 'dimensions', 'Dimensions confirmed',
'We know the vessel''s length, width, and draft.', true, 1
FROM ports p
ON CONFLICT (port_id, key) DO NOTHING;
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
SELECT p.id, 'intent', 'Intent confirmed',
'Client has explicitly confirmed they want a berth at this marina.', true, 2
FROM ports p
ON CONFLICT (port_id, key) DO NOTHING;
-- These are built but disabled — admins enable per port when relevant.
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
SELECT p.id, 'signatory', 'Buyer signatory confirmed',
'We know who is authorized to sign the EOI on the buyer side (owner / lawyer / company rep).',
false, 3
FROM ports p
ON CONFLICT (port_id, key) DO NOTHING;
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
SELECT p.id, 'timeline', 'Move-in timeline confirmed',
'Client has indicated when they want to start using the berth.',
false, 4
FROM ports p
ON CONFLICT (port_id, key) DO NOTHING;
-- ─── Per-interest qualification state ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS interest_qualifications (
interest_id text NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
criterion_key text NOT NULL,
confirmed boolean NOT NULL DEFAULT false,
confirmed_at timestamptz,
confirmed_by text,
notes text,
PRIMARY KEY (interest_id, criterion_key)
);
CREATE INDEX IF NOT EXISTS idx_interest_qualifications_interest
ON interest_qualifications (interest_id);
-- ─── Payment records (no invoice generation) ───────────────────────────────
CREATE TABLE IF NOT EXISTS payments (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
interest_id text NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
payment_type text NOT NULL, -- 'deposit' | 'balance' | 'refund' | 'other'
amount numeric NOT NULL,
currency text NOT NULL DEFAULT 'EUR',
received_at timestamptz NOT NULL,
receipt_file_id text REFERENCES files(id),
notes text,
recorded_by text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_payments_interest ON payments (interest_id);
CREATE INDEX IF NOT EXISTS idx_payments_client ON payments (client_id);
CREATE INDEX IF NOT EXISTS idx_payments_port ON payments (port_id);
CREATE INDEX IF NOT EXISTS idx_payments_type ON payments (port_id, payment_type);

View File

@@ -0,0 +1,14 @@
-- 0063_contact_log_voice_template.sql
-- ----------------------------------------------------------------------------
-- Pipeline-refactor follow-up. The contact-log captures sales-rep
-- interactions; the v1 UX adds two new columns:
-- - voice_transcript: raw Web Speech API output, kept separate from the
-- rep-polished `summary` so we can re-render the
-- transcript verbatim if the rep wants to revisit it.
-- - template_used: which of the 3 quick-template buttons was tapped, if
-- any. Surfaces in reports so admins can see how reps
-- actually log activity (call/visit/email).
ALTER TABLE interest_contact_log
ADD COLUMN IF NOT EXISTS voice_transcript text,
ADD COLUMN IF NOT EXISTS template_used text;

View File

@@ -68,5 +68,8 @@ export * from './website-submissions';
// Pre-EOI supplemental form tokens
export * from './supplemental-forms';
// Pipeline refactor — qualification criteria, payment records
export * from './pipeline';
// Relations (must come last - references all tables)
export * from './relations';

View File

@@ -15,7 +15,8 @@ import { clients } from './clients';
import { berths } from './berths';
import { yachts } from './yachts';
// Pipeline stages: open, details_sent, in_communication, eoi_sent, eoi_signed, deposit_10pct, contract_sent, contract_signed, completed
// Pipeline stages: enquiry, qualified, nurturing, eoi, reservation, deposit_paid, contract
// (doc sub-status carried on eoi_doc_status / reservation_doc_status / contract_doc_status)
export const interests = pgTable(
'interests',
@@ -30,7 +31,19 @@ export const interests = pgTable(
.notNull()
.references(() => clients.id),
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
pipelineStage: text('pipeline_stage').notNull().default('open'),
/** Who owns this deal. Auto-assigned on create from system_settings
* `default_new_interest_owner`; reassignable via the interest header. */
assignedTo: text('assigned_to'),
pipelineStage: text('pipeline_stage').notNull().default('enquiry'),
/** Sub-status for the doc-signing stages. NULL while the deal hasn't
* reached the stage yet; 'pending' | 'sent' | 'signed' | 'declined' | 'voided'. */
eoiDocStatus: text('eoi_doc_status'),
reservationDocStatus: text('reservation_doc_status'),
contractDocStatus: text('contract_doc_status'),
/** Documenso IDs per document type. EOI uses the existing `documensoId`
* for backward compat with the template-generate path. */
reservationDocumensoId: text('reservation_documenso_id'),
contractDocumensoId: text('contract_documenso_id'),
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
source: text('source'), // website, manual, referral, broker
eoiStatus: text('eoi_status'), // null, waiting_for_signatures, signed, expired
@@ -38,10 +51,16 @@ export const interests = pgTable(
contractStatus: text('contract_status'),
depositStatus: text('deposit_status'),
reservationStatus: text('reservation_status'),
/** Agreed deposit amount captured at reservation-agreement time. Lets
* the payments running-total decide when the deposit is "fully paid"
* and the stage advances automatically. */
depositExpectedAmount: numeric('deposit_expected_amount'),
depositExpectedCurrency: text('deposit_expected_currency').default('EUR'),
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
dateEoiSent: timestamp('date_eoi_sent', { withTimezone: true }),
dateEoiSigned: timestamp('date_eoi_signed', { withTimezone: true }),
dateReservationSigned: timestamp('date_reservation_signed', { withTimezone: true }),
dateContractSent: timestamp('date_contract_sent', { withTimezone: true }),
dateContractSigned: timestamp('date_contract_signed', { withTimezone: true }),
dateDepositReceived: timestamp('date_deposit_received', { withTimezone: true }),
@@ -86,6 +105,7 @@ export const interests = pgTable(
.on(table.portId)
.where(sql`${table.archivedAt} IS NULL`),
index('idx_interests_outcome').on(table.portId, table.outcome),
index('idx_interests_assigned_to').on(table.assignedTo),
],
);

View File

@@ -231,6 +231,13 @@ export const interestContactLog = pgTable(
direction: text('direction').notNull().default('outbound'),
/** Short free text — "Discussed yacht size, asked about tax structure". */
summary: text('summary').notNull(),
/** Raw Web Speech API transcript captured at log time, kept separate
* from the rep-polished `summary` so the original utterance survives
* edits to the summary text. NULL when the rep typed manually. */
voiceTranscript: text('voice_transcript'),
/** Which of the 3 quick-template buttons was tapped on the log modal
* ('call' | 'visit' | 'email'). NULL when the rep filled in freeform. */
templateUsed: text('template_used'),
/** Optional. When set, a reminder is auto-created pointing back to
* the interest for follow-up. Stored as the original choice so the
* UI can re-render it; the actual reminder lives in `reminders`. */

View File

@@ -0,0 +1,125 @@
/**
* Pipeline-refactor tables — per-port qualification criteria, per-interest
* qualification state, and payment records (no invoice generation).
*
* See migrations/0062_pipeline_refactor.sql.
*/
import {
pgTable,
text,
boolean,
integer,
numeric,
timestamp,
index,
primaryKey,
} from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { interests } from './interests';
import { clients } from './clients';
import { files } from './documents';
/**
* Per-port qualification criteria. Admin-configurable: enable/disable,
* rename labels, reorder. The default seed is 2 enabled (dimensions +
* intent) and 2 disabled (signatory + timeline) per port.
*/
export const qualificationCriteria = pgTable(
'qualification_criteria',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
/** Stable key for code references. e.g. 'dimensions', 'intent'. */
key: text('key').notNull(),
/** Display label shown on the qualification checklist UI. */
label: text('label').notNull(),
/** Optional short description shown as helper text under the checkbox. */
description: text('description'),
enabled: boolean('enabled').notNull().default(true),
displayOrder: integer('display_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_qualification_criteria_port').on(table.portId)],
);
/**
* Per-interest qualification state. Composite PK (interest_id, criterion_key)
* lets the same key exist across ports without conflicts and gives O(1)
* lookup for the "all enabled criteria confirmed" check.
*/
export const interestQualifications = pgTable(
'interest_qualifications',
{
interestId: text('interest_id')
.notNull()
.references(() => interests.id, { onDelete: 'cascade' }),
criterionKey: text('criterion_key').notNull(),
confirmed: boolean('confirmed').notNull().default(false),
confirmedAt: timestamp('confirmed_at', { withTimezone: true }),
confirmedBy: text('confirmed_by'),
notes: text('notes'),
},
(table) => [
primaryKey({ columns: [table.interestId, table.criterionKey] }),
index('idx_interest_qualifications_interest').on(table.interestId),
],
);
/**
* Payment records. The CRM does NOT generate invoices — clients pay banks
* directly. We record that money was received (or refunded) with an
* optional uploaded receipt for audit purposes.
*
* The "deposit_paid" stage auto-advances when SUM(payments where type=deposit)
* for an interest reaches the `interests.depositExpectedAmount`.
*/
export const payments = pgTable(
'payments',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
interestId: text('interest_id')
.notNull()
.references(() => interests.id, { onDelete: 'cascade' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
/** 'deposit' | 'balance' | 'refund' | 'other' — `refund` rows carry
* negative amounts so the running total nets out correctly. */
paymentType: text('payment_type').notNull(),
amount: numeric('amount').notNull(),
currency: text('currency').notNull().default('EUR'),
receivedAt: timestamp('received_at', { withTimezone: true }).notNull(),
/** Optional uploaded receipt PDF. The UI warns reps that recording
* without a receipt may make later verification harder, but doesn't
* block the save. */
receiptFileId: text('receipt_file_id').references(() => files.id),
notes: text('notes'),
recordedBy: text('recorded_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_payments_interest').on(table.interestId),
index('idx_payments_client').on(table.clientId),
index('idx_payments_port').on(table.portId),
index('idx_payments_type').on(table.portId, table.paymentType),
],
);
export type QualificationCriterion = typeof qualificationCriteria.$inferSelect;
export type NewQualificationCriterion = typeof qualificationCriteria.$inferInsert;
export type InterestQualification = typeof interestQualifications.$inferSelect;
export type NewInterestQualification = typeof interestQualifications.$inferInsert;
export type Payment = typeof payments.$inferSelect;
export type NewPayment = typeof payments.$inferInsert;

View File

@@ -828,15 +828,13 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
berthIdx: number | null;
yachtIdx: number | null;
pipelineStage:
| 'open'
| 'details_sent'
| 'in_communication'
| 'eoi_sent'
| 'eoi_signed'
| 'deposit_10pct'
| 'contract_sent'
| 'contract_signed'
| 'completed';
| 'enquiry'
| 'qualified'
| 'nurturing'
| 'eoi'
| 'reservation'
| 'deposit_paid'
| 'contract';
leadCategory: 'general_interest' | 'specific_qualified' | 'hot_lead';
source: 'website' | 'manual' | 'referral' | 'broker';
daysAgoFirst: number;
@@ -846,7 +844,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 0,
berthIdx: 0,
yachtIdx: 0,
pipelineStage: 'open',
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 5,
@@ -855,7 +853,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 1,
berthIdx: 1,
yachtIdx: 1,
pipelineStage: 'details_sent',
pipelineStage: 'qualified',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 12,
@@ -864,7 +862,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 2,
berthIdx: 2,
yachtIdx: 2,
pipelineStage: 'in_communication',
pipelineStage: 'qualified',
leadCategory: 'specific_qualified',
source: 'referral',
daysAgoFirst: 25,
@@ -873,7 +871,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 3,
berthIdx: 3,
yachtIdx: 6,
pipelineStage: 'eoi_sent',
pipelineStage: 'eoi',
leadCategory: 'specific_qualified',
source: 'referral',
daysAgoFirst: 40,
@@ -882,7 +880,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 4,
berthIdx: 4,
yachtIdx: null,
pipelineStage: 'open',
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'broker',
daysAgoFirst: 8,
@@ -891,7 +889,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 5,
berthIdx: 5,
yachtIdx: 3,
pipelineStage: 'eoi_signed',
pipelineStage: 'eoi',
leadCategory: 'hot_lead',
source: 'manual',
daysAgoFirst: 55,
@@ -900,7 +898,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 6,
berthIdx: 6,
yachtIdx: 4,
pipelineStage: 'deposit_10pct',
pipelineStage: 'deposit_paid',
leadCategory: 'hot_lead',
source: 'referral',
daysAgoFirst: 70,
@@ -909,7 +907,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 0,
berthIdx: 7,
yachtIdx: 5,
pipelineStage: 'contract_signed',
pipelineStage: 'contract',
leadCategory: 'hot_lead',
source: 'broker',
daysAgoFirst: 90,
@@ -918,7 +916,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 1,
berthIdx: 10,
yachtIdx: 1,
pipelineStage: 'completed',
pipelineStage: 'contract',
leadCategory: 'hot_lead',
source: 'referral',
daysAgoFirst: 240,
@@ -927,7 +925,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 7,
berthIdx: 11,
yachtIdx: 11,
pipelineStage: 'completed',
pipelineStage: 'contract',
leadCategory: 'hot_lead',
source: 'manual',
daysAgoFirst: 320,
@@ -936,7 +934,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 2,
berthIdx: null,
yachtIdx: null,
pipelineStage: 'open',
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 3,
@@ -945,7 +943,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 3,
berthIdx: 8,
yachtIdx: 6,
pipelineStage: 'in_communication',
pipelineStage: 'qualified',
leadCategory: 'specific_qualified',
source: 'website',
daysAgoFirst: 18,
@@ -954,7 +952,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 5,
berthIdx: null,
yachtIdx: 3,
pipelineStage: 'details_sent',
pipelineStage: 'qualified',
leadCategory: 'general_interest',
source: 'referral',
daysAgoFirst: 10,
@@ -964,7 +962,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 4,
berthIdx: 2,
yachtIdx: null,
pipelineStage: 'open',
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website',
daysAgoFirst: 180,
@@ -974,7 +972,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
clientIdx: 6,
berthIdx: 9,
yachtIdx: 4,
pipelineStage: 'eoi_sent',
pipelineStage: 'eoi',
leadCategory: 'specific_qualified',
source: 'broker',
daysAgoFirst: 45,

View File

@@ -100,9 +100,14 @@ interface SyntheticClientSpec {
postalCode: string;
/** Pipeline stage of the (single) interest. Omit for archived-only clients. */
stage?: PipelineStage;
/** Sub-status badges for the doc-signing stages (eoi / reservation / contract).
* Only meaningful when stage matches; otherwise null/undefined. */
eoiDocStatus?: 'sent' | 'signed';
reservationDocStatus?: 'sent' | 'signed';
contractDocStatus?: 'sent' | 'signed';
/** Index into BERTH_SNAPSHOT for the primary linked berth. */
berthIdx?: number;
/** Mark interest as won/lost when stage = completed. */
/** Mark interest as won/lost when stage = contract+signed. */
outcome?: 'won' | 'lost_unqualified' | 'lost_no_response';
/** Archive the CLIENT after creation. When 'rich', fabricate
* archive_metadata so the smart-restore wizard surfaces reversals. */
@@ -145,7 +150,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'London',
street: '14 Cheyne Walk',
postalCode: 'SW3 5RA',
stage: 'open',
stage: 'enquiry',
source: 'website',
createdDaysAgo: 4,
// Open stage: no berth link yet
@@ -159,7 +164,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Miami',
street: '880 Brickell Bay Drive',
postalCode: '33131',
stage: 'details_sent',
stage: 'enquiry',
berthIdx: 0,
source: 'broker',
createdDaysAgo: 12,
@@ -173,7 +178,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Palma de Mallorca',
street: 'Carrer de Sant Magí 23',
postalCode: '07013',
stage: 'in_communication',
stage: 'qualified',
berthIdx: 5,
source: 'referral',
createdDaysAgo: 28,
@@ -187,7 +192,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Genoa',
street: 'Via XX Settembre 47',
postalCode: '16121',
stage: 'eoi_sent',
stage: 'eoi',
eoiDocStatus: 'sent',
berthIdx: 6,
source: 'broker',
createdDaysAgo: 45,
@@ -201,7 +207,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Nice',
street: '8 Promenade des Anglais',
postalCode: '06000',
stage: 'eoi_signed',
stage: 'eoi',
eoiDocStatus: 'signed',
berthIdx: 7,
source: 'website',
createdDaysAgo: 72,
@@ -215,7 +222,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Athens',
street: 'Vouliagmenis Avenue 142',
postalCode: '16674',
stage: 'deposit_10pct',
stage: 'deposit_paid',
berthIdx: 8,
source: 'referral',
createdDaysAgo: 95,
@@ -229,7 +236,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Dublin',
street: '12 Merrion Square North',
postalCode: 'D02 E2X3',
stage: 'contract_sent',
stage: 'contract',
contractDocStatus: 'sent',
berthIdx: 9,
source: 'manual',
createdDaysAgo: 118,
@@ -243,7 +251,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Lisbon',
street: 'Rua Garrett 88',
postalCode: '1200-205',
stage: 'contract_signed',
stage: 'contract',
contractDocStatus: 'signed',
berthIdx: 4,
source: 'broker',
createdDaysAgo: 156,
@@ -257,7 +266,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Panama City',
street: 'Calle 50, Torre Banistmo Piso 18',
postalCode: '0816',
stage: 'completed',
stage: 'contract',
contractDocStatus: 'signed',
berthIdx: 10,
outcome: 'won',
source: 'referral',
@@ -272,7 +282,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Hamburg',
street: 'Alsterufer 28',
postalCode: '20354',
stage: 'completed',
stage: 'enquiry',
berthIdx: 1,
outcome: 'lost_unqualified',
source: 'website',
@@ -552,19 +562,21 @@ export async function seedSyntheticPortData(
// ── 6. Berth status overrides for linked moorings ───────────────────────
// Match the dossier classification to the berth's pipeline stage.
// For under_offer-wave clients (eoi_sent → contract_sent), force the
// berth to under_offer. For completed-won, mark the berth sold.
// Sold = contract+signed+won. Under offer = active berth-bearing stages
// (eoi / reservation / deposit_paid / contract-not-yet-won).
const stageToBerthStatus = (
stage: PipelineStage | undefined,
spec: SyntheticClientSpec,
): 'available' | 'under_offer' | 'sold' | null => {
const stage = spec.stage;
if (!stage) return null;
if (stage === 'completed') return 'sold';
if (stage === 'contract' && spec.contractDocStatus === 'signed' && spec.outcome === 'won') {
return 'sold';
}
if (
stage === 'eoi_sent' ||
stage === 'eoi_signed' ||
stage === 'deposit_10pct' ||
stage === 'contract_sent' ||
stage === 'contract_signed'
stage === 'eoi' ||
stage === 'reservation' ||
stage === 'deposit_paid' ||
stage === 'contract'
) {
return 'under_offer';
}
@@ -573,7 +585,7 @@ export async function seedSyntheticPortData(
for (const spec of PIPELINE_CLIENTS) {
if (spec.berthIdx === undefined) continue;
const newStatus = stageToBerthStatus(spec.stage);
const newStatus = stageToBerthStatus(spec);
if (!newStatus) continue;
const berthId = berthRows[spec.berthIdx]!.id;
await tx.update(berths).set({ status: newStatus }).where(eq(berths.id, berthId));
@@ -584,20 +596,33 @@ export async function seedSyntheticPortData(
for (const spec of PIPELINE_CLIENTS) {
if (!spec.stage) continue;
const clientId = idByTag.get(spec.tag)!;
// Derive deal age from the (stage, doc-sub-status) pair so a
// contract+signed+won record looks older than a brand-new enquiry.
const stageDaysAgoMap: Record<PipelineStage, number> = {
open: 1,
details_sent: 5,
in_communication: 10,
eoi_sent: 20,
eoi_signed: 35,
deposit_10pct: 60,
contract_sent: 80,
contract_signed: 110,
completed: spec.outcome === 'won' ? 200 : 60,
enquiry: 5,
qualified: 10,
nurturing: 30,
eoi: spec.eoiDocStatus === 'signed' ? 35 : 20,
reservation: 50,
deposit_paid: 60,
contract: spec.outcome === 'won' ? 200 : spec.contractDocStatus === 'signed' ? 110 : 80,
};
const ageDays = stageDaysAgoMap[spec.stage];
const yachtId = spec.tag === 'completed-won' ? charterYachtRow[0]!.id : null;
// Stage-progression flags so the date_* timestamps cascade correctly.
// Anything past "eoi+sent" implies the EOI was at least sent.
const eoiReached =
spec.stage === 'eoi' ||
spec.stage === 'reservation' ||
spec.stage === 'deposit_paid' ||
spec.stage === 'contract';
const eoiSigned =
(spec.stage === 'eoi' && spec.eoiDocStatus === 'signed') ||
spec.stage === 'reservation' ||
spec.stage === 'deposit_paid' ||
spec.stage === 'contract';
const [intRow] = await tx
.insert(interests)
.values({
@@ -605,40 +630,24 @@ export async function seedSyntheticPortData(
clientId,
yachtId,
pipelineStage: spec.stage,
eoiDocStatus: spec.eoiDocStatus ?? (eoiSigned ? 'signed' : null),
reservationDocStatus: spec.reservationDocStatus ?? null,
contractDocStatus: spec.contractDocStatus ?? null,
leadCategory:
spec.stage === 'open'
spec.stage === 'enquiry'
? 'general_interest'
: spec.stage === 'details_sent' || spec.stage === 'in_communication'
: spec.stage === 'qualified' || spec.stage === 'nurturing'
? 'specific_qualified'
: 'hot_lead',
source: 'manual' as const,
dateFirstContact: daysAgo(ageDays),
dateLastContact: daysAgo(Math.max(0, ageDays - 2)),
dateEoiSent:
spec.stage === 'eoi_sent' ||
spec.stage === 'eoi_signed' ||
spec.stage === 'deposit_10pct' ||
spec.stage === 'contract_sent' ||
spec.stage === 'contract_signed' ||
spec.stage === 'completed'
? daysAgo(Math.max(0, ageDays - 5))
: null,
dateEoiSigned:
spec.stage === 'eoi_signed' ||
spec.stage === 'deposit_10pct' ||
spec.stage === 'contract_sent' ||
spec.stage === 'contract_signed' ||
spec.stage === 'completed'
? daysAgo(Math.max(0, ageDays - 10))
: null,
dateEoiSent: eoiReached ? daysAgo(Math.max(0, ageDays - 5)) : null,
dateEoiSigned: eoiSigned ? daysAgo(Math.max(0, ageDays - 10)) : null,
eoiStatus:
spec.stage === 'eoi_sent'
spec.stage === 'eoi' && spec.eoiDocStatus === 'sent'
? 'waiting_for_signatures'
: spec.stage === 'eoi_signed' ||
spec.stage === 'deposit_10pct' ||
spec.stage === 'contract_sent' ||
spec.stage === 'contract_signed' ||
spec.stage === 'completed'
: eoiSigned
? 'signed'
: null,
outcome: spec.outcome ?? null,
@@ -656,7 +665,7 @@ export async function seedSyntheticPortData(
berthId,
isPrimary: true,
isSpecificInterest: true,
isInEoiBundle: spec.stage !== 'open' && spec.stage !== 'details_sent',
isInEoiBundle: spec.stage !== 'enquiry' && spec.stage !== 'qualified',
addedBy: SUPER_ADMIN_USER_ID,
});
}
@@ -672,7 +681,7 @@ export async function seedSyntheticPortData(
portId,
clientId: carlaId,
yachtId: null,
pipelineStage: 'open',
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website' as const,
dateFirstContact: daysAgo(2),

View File

@@ -187,9 +187,9 @@ export async function seedWideSyntheticPortData(
yachtId: null,
pipelineStage: stage,
leadCategory:
stage === 'open'
stage === 'enquiry'
? 'general_interest'
: stage === 'details_sent' || stage === 'in_communication'
: stage === 'qualified' || stage === 'nurturing'
? 'specific_qualified'
: 'hot_lead',
source: sourceChoice.source,

View File

@@ -25,8 +25,9 @@ export async function withTransaction<T>(callback: (tx: typeof db) => Promise<T>
}
/**
* Soft-deletes a record by setting `archived_at` to now.
* The table must have an `archived_at` column.
* Soft-deletes a record by setting `archivedAt` to now.
* The table must have an `archivedAt` JS property mapping to the
* `archived_at` column.
*
* @example
* await softDelete(clients, clients.id, clientId);
@@ -36,15 +37,22 @@ export async function softDelete<TTable extends PgTable>(
idColumn: PgColumn,
id: string,
): Promise<void> {
// Drizzle's `.set()` API expects the JS property name (`archivedAt`),
// not the snake-case column name. The previous version passed
// `{ archived_at: ... }` which silently produced an empty SET clause
// (Drizzle skipped the unknown property) → `UPDATE ... where ...`
// syntax error from Postgres. Caught by QA: archive an interest
// with a berth attached → 500. Same fix in restore() below.
await db
.update(table)
.set({ archived_at: sql`now()` } as Record<string, unknown>)
.set({ archivedAt: sql`now()` } as Record<string, unknown>)
.where(eq(idColumn, id));
}
/**
* Restores a soft-deleted record by clearing `archived_at`.
* The table must have an `archived_at` column.
* Restores a soft-deleted record by clearing `archivedAt`.
* The table must have an `archivedAt` JS property mapping to the
* `archived_at` column.
*
* @example
* await restore(clients, clients.id, clientId);
@@ -56,6 +64,6 @@ export async function restore<TTable extends PgTable>(
): Promise<void> {
await db
.update(table)
.set({ archived_at: null } as Record<string, unknown>)
.set({ archivedAt: null } as Record<string, unknown>)
.where(eq(idColumn, id));
}

View File

@@ -51,16 +51,13 @@ export interface InterestSummaryPdfProps {
}
const STAGE_TONE: Record<string, BadgeTone> = {
open: 'neutral',
details_sent: 'neutral',
in_communication: 'neutral',
eoi_sent: 'accent',
eoi_signed: 'accent',
deposit_10pct: 'warning',
contract_sent: 'warning',
contract_signed: 'success',
completed: 'success',
cancelled: 'danger',
enquiry: 'neutral',
qualified: 'neutral',
nurturing: 'neutral',
eoi: 'accent',
reservation: 'warning',
deposit_paid: 'warning',
contract: 'success',
};
function fmt(d: Date | string | null | undefined): string {

View File

@@ -16,15 +16,13 @@ export interface PipelineReportPdfProps {
}
const FUNNEL_STAGES = [
'open',
'details_sent',
'in_communication',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
'enquiry',
'qualified',
'nurturing',
'eoi',
'reservation',
'deposit_paid',
'contract',
];
interface TopInterestRow {

View File

@@ -19,15 +19,13 @@ export interface RevenueReportPdfProps {
}
const STAGE_ORDER = [
'open',
'details_sent',
'in_communication',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
'enquiry',
'qualified',
'nurturing',
'eoi',
'reservation',
'deposit_paid',
'contract',
];
function fmtAmount(n: number, currency: string): string {

View File

@@ -133,7 +133,8 @@ export async function computePipelineFunnel(
.groupBy(interests.pipelineStage);
const counts = new Map(stageRows.map((r) => [r.stage, r.count]));
const top = counts.get('open') ?? 0;
// First stage in the canonical order anchors the conversion percentage.
const top = counts.get(PIPELINE_STAGES[0]) ?? 0;
const stages = PIPELINE_STAGES.map((stage) => {
const count = counts.get(stage) ?? 0;

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
@@ -7,6 +7,7 @@ import { systemSettings } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { emitToRoom } from '@/lib/socket/server';
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
import { logger } from '@/lib/logger';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -94,45 +95,105 @@ export async function evaluateRule(
const rulesConfig = await getRulesConfig(portId);
const rule = rulesConfig[trigger];
// Decision-trace audit: ALWAYS record what we decided to do (or not do),
// including the rule mode, so admins can debug "why didn't this fire?" /
// "why did this fire" without grepping server logs. Tagged `berth_rule_decision`
// so it's distinct from the actual mutation audit row below.
void createAuditLog({
userId: meta.userId,
portId,
action: 'rule_evaluated',
entityType: 'berth',
entityId: targetBerthId,
metadata: {
type: 'berth_rule_decision',
trigger,
mode: rule.mode,
targetStatus: rule.targetStatus,
interestId,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
if (rule.mode === 'off') {
return { action: 'none' };
}
if (rule.mode === 'auto') {
await db
.update(berths)
.set({
status: rule.targetStatus,
statusLastChangedBy: meta.userId,
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
statusLastModified: new Date(),
updatedAt: new Date(),
})
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
// Concurrency hardening: wrap the read-then-write in a transaction with a
// berth-scoped advisory lock so two concurrent webhook retries can't both
// commit the same status flip (which produces duplicate audit rows + a
// double socket emit). Also short-circuit when the target status is
// already in place — re-writing 'sold'→'sold' is technically harmless
// but pollutes the audit trail and the socket stream.
const result = await db.transaction(async (tx) => {
// pg_advisory_xact_lock takes a single bigint. We hash port+berth into
// a stable 32-bit slot. The lock auto-releases at transaction end so
// there's no risk of a stuck lock if the handler crashes mid-write.
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtext(${`berth-rule:${portId}:${targetBerthId}`}))`,
);
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: targetBerthId,
newValue: { status: rule.targetStatus },
metadata: { type: 'berth_rule_auto', trigger, interestId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
// Re-read inside the lock so we observe the post-lock state, not the
// pre-lock snapshot. If the prior contender already moved status to
// our target, we're idempotent and bail.
const [current] = await tx
.select({ status: berths.status })
.from(berths)
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
if (!current) return { changed: false as const };
if (current.status === rule.targetStatus) {
// Idempotent re-fire. We already audited the decision above; nothing
// more to do here.
logger.debug(
{ trigger, targetBerthId, portId, status: current.status },
'Berth-rule auto: target status already set, skipping duplicate write',
);
return { changed: false as const };
}
await tx
.update(berths)
.set({
status: rule.targetStatus,
statusLastChangedBy: meta.userId,
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
statusLastModified: new Date(),
updatedAt: new Date(),
})
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
return { changed: true as const, previousStatus: current.status };
});
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
berthId: targetBerthId,
newStatus: rule.targetStatus,
triggeredBy: meta.userId,
trigger,
});
if (result.changed) {
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: targetBerthId,
oldValue: { status: result.previousStatus },
newValue: { status: rule.targetStatus },
metadata: { type: 'berth_rule_auto', trigger, interestId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
berthId: targetBerthId,
newStatus: rule.targetStatus,
triggeredBy: meta.userId,
trigger,
});
}
return { action: 'applied', newStatus: rule.targetStatus };
}
// suggest mode
// suggest mode — the decision-trace audit above already records the suggestion.
return {
action: 'suggested',
newStatus: rule.targetStatus,

View File

@@ -34,10 +34,8 @@ import type { PipelineStage } from '@/lib/constants';
* a reason for these clients.
*/
export const HIGH_STAKES_STAGES: ReadonlySet<PipelineStage> = new Set<PipelineStage>([
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
'deposit_paid',
'contract',
]);
export type ArchiveStakeLevel = 'low' | 'high';
@@ -433,18 +431,18 @@ export async function getClientArchiveDossier(
}
// Stage rank used to pick the "highest" high-stakes stage when surfacing
// the warning copy. Higher = more committed.
// the warning copy. Higher = more committed. Doc sub-status is folded back
// in via the caller (treating eoi+signed as past nurturing, contract+signed
// as the apex).
function rankStage(s: PipelineStage): number {
switch (s) {
case 'completed':
return 5;
case 'contract_signed':
case 'contract':
return 4;
case 'contract_sent':
case 'deposit_paid':
return 3;
case 'deposit_10pct':
case 'reservation':
return 2;
case 'eoi_signed':
case 'eoi':
return 1;
default:
return 0;

View File

@@ -345,17 +345,28 @@ export async function uploadDocumentForSigning(
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
.where(eq(documents.id, docRow.id));
// Pipeline transition: contract_sent stage when contract or
// reservation_agreement goes out for signing. eoi_sent is reserved
// for the template-driven EOI flow. No berth-rules trigger here —
// the rules engine fires on `contract_signed` (webhook-driven).
// Pipeline transition: contract / reservation custom-upload goes out
// for signing. Stamps the matching doc-status sub-state so the badge
// flips to 'sent' immediately. EOI stage is reserved for the template
// pathway and stamped from documents.service.ts. No berth-rules trigger
// here — the rules engine fires on `contract_signed` (webhook-driven).
const targetStage = documentType === 'contract' ? 'contract' : 'reservation';
void advanceStageIfBehind(
interestId,
portId,
'contract_sent',
targetStage,
meta,
`${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`,
);
await db
.update(interests)
.set({
...(documentType === 'contract'
? { contractDocStatus: 'sent', dateContractSent: new Date() }
: { reservationDocStatus: 'sent' }),
updatedAt: new Date(),
})
.where(eq(interests.id, interestId));
void createAuditLog({
userId: meta.userId,

View File

@@ -0,0 +1,212 @@
/**
* Rule-based deal-health scoring. NO LLMs — every output traces back to a
* dated/structured input the rep can see and contest. The chip displayed
* on the interest header exposes the per-signal breakdown via tooltip so
* an anti-AI stakeholder reading the screen never sees a black box.
*
* Inputs (all already available on getInterestById):
* - pipelineStage + per-doc sub-status (eoiDocStatus, etc.)
* - dateLastContact, dateFirstContact, dateEoiSent, dateEoiSigned,
* dateReservationSigned, dateContractSent, dateContractSigned,
* dateDepositReceived
* - depositExpectedAmount (numeric string)
*
* Scoring rubric (0100, higher is healthier):
* Base 50.
* +5 if any activity log entry landed in the last 7 days (active engagement).
* +20 if the rep has logged contact in the last 7 days.
* +10 if contact within 14 days (and the 7d bonus didn't fire).
* -15 if no contact logged in 30+ days.
* -10 if the deal is older than 30d and still in 'enquiry' or 'qualified'.
* +10 for each stage past enquiry the deal has reached (capped at +30).
* -10 if EOI was sent more than 14d ago and isn't signed yet.
* -10 if reservation was signed but no deposit recorded in 21d.
* -10 if contract was sent more than 14d ago and isn't signed yet.
* +5 if outcome is 'won' (sanity bump, though won deals don't show this).
*
* Score buckets:
* ≥70 → 'hot' (green; rep is on top of it)
* 40-69 → 'warm' (amber; needs attention)
* <40 → 'cold' (rose; at risk)
*
* The full signals[] array is surfaced to the UI so the tooltip can render
* "+20 contacted 3 days ago", "-10 EOI awaiting signature 19d" etc.
*/
import type { PipelineStage } from '@/lib/constants';
import { PIPELINE_STAGES } from '@/lib/constants';
export interface DealHealthInput {
pipelineStage: string;
outcome?: string | null;
archivedAt?: string | null;
dateFirstContact?: string | Date | null;
dateLastContact?: string | Date | null;
dateEoiSent?: string | Date | null;
dateEoiSigned?: string | Date | null;
dateReservationSigned?: string | Date | null;
dateContractSent?: string | Date | null;
dateContractSigned?: string | Date | null;
dateDepositReceived?: string | Date | null;
eoiDocStatus?: string | null;
reservationDocStatus?: string | null;
contractDocStatus?: string | null;
/** Optional: count of contact_log entries in the last 7 days. Drives the
* +5 "active engagement" signal. When omitted the signal is skipped — keep
* the scoring function pure / synchronous so the chip can render without a
* separate fetch on every interest list row. */
recentActivityCount?: number | null;
}
export interface DealHealthSignal {
/** Stable id useful for keying the tooltip rows. */
id: string;
/** +N or -N (signed integer for explicit math). */
delta: number;
/** Plain-English explanation surfaced in the tooltip. */
detail: string;
}
export interface DealHealth {
score: number;
pulse: 'cold' | 'warm' | 'hot';
signals: DealHealthSignal[];
}
function daysSince(iso: string | Date | null | undefined): number | null {
if (!iso) return null;
const t = iso instanceof Date ? iso.getTime() : new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.floor((Date.now() - t) / 86_400_000);
}
export function computeDealHealth(input: DealHealthInput): DealHealth {
let score = 50;
const signals: DealHealthSignal[] = [];
// Closed / archived deals don't get a pulse score — UI hides the chip
// anyway, but compute a neutral score so callers using this in reports
// don't crash on undefined.
if (input.archivedAt || input.outcome) {
return { score: 50, pulse: 'warm', signals: [] };
}
// Active engagement: counts every distinct activity-log entry in the last
// 7 days. Surfaces "the rep is actively working this deal" separately from
// the coarse dateLastContact bump (which only moves on the most-recent
// entry's date). A 5-call week scores +5 once; we don't double-count.
if (input.recentActivityCount !== null && input.recentActivityCount !== undefined) {
if (input.recentActivityCount >= 1) {
score += 5;
signals.push({
id: 'active_engagement',
delta: +5,
detail: `${input.recentActivityCount} activity log entr${
input.recentActivityCount === 1 ? 'y' : 'ies'
} in the last 7d — rep is engaged.`,
});
}
}
const contactDays = daysSince(input.dateLastContact);
if (contactDays !== null) {
if (contactDays <= 7) {
score += 20;
signals.push({
id: 'contact_recent',
delta: +20,
detail: `Contact logged ${contactDays}d ago — fresh.`,
});
} else if (contactDays <= 14) {
score += 10;
signals.push({
id: 'contact_warm',
delta: +10,
detail: `Contact logged ${contactDays}d ago — still warm.`,
});
} else if (contactDays >= 30) {
score -= 15;
signals.push({
id: 'contact_stale',
delta: -15,
detail: `No contact logged in ${contactDays}d — going cold.`,
});
}
}
// Stage progress: every step past enquiry signals momentum.
const stageIdx = PIPELINE_STAGES.indexOf(input.pipelineStage as PipelineStage);
if (stageIdx > 0) {
const bonus = Math.min(30, stageIdx * 10);
score += bonus;
signals.push({
id: 'stage_progress',
delta: +bonus,
detail: `Reached ${input.pipelineStage.replace(/_/g, ' ')} stage.`,
});
}
// Age penalty for stuck top-of-funnel leads.
const firstDays = daysSince(input.dateFirstContact);
if (
firstDays !== null &&
firstDays >= 30 &&
(input.pipelineStage === 'enquiry' || input.pipelineStage === 'qualified')
) {
score -= 10;
signals.push({
id: 'stuck_top_funnel',
delta: -10,
detail: `Deal opened ${firstDays}d ago and still pre-EOI.`,
});
}
// EOI in-flight too long.
const eoiSentDays = daysSince(input.dateEoiSent);
if (
eoiSentDays !== null &&
eoiSentDays >= 14 &&
input.eoiDocStatus !== 'signed' &&
!input.dateEoiSigned
) {
score -= 10;
signals.push({
id: 'eoi_awaiting',
delta: -10,
detail: `EOI awaiting signature for ${eoiSentDays}d.`,
});
}
// Reservation signed but deposit not received.
const reservationDays = daysSince(input.dateReservationSigned);
if (reservationDays !== null && reservationDays >= 21 && !input.dateDepositReceived) {
score -= 10;
signals.push({
id: 'deposit_pending',
delta: -10,
detail: `Reservation signed ${reservationDays}d ago but deposit not recorded.`,
});
}
// Contract awaiting signature.
const contractSentDays = daysSince(input.dateContractSent);
if (
contractSentDays !== null &&
contractSentDays >= 14 &&
input.contractDocStatus !== 'signed' &&
!input.dateContractSigned
) {
score -= 10;
signals.push({
id: 'contract_awaiting',
delta: -10,
detail: `Contract awaiting signature for ${contractSentDays}d.`,
});
}
// Clamp to [0, 100].
score = Math.max(0, Math.min(100, score));
const pulse: DealHealth['pulse'] = score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold';
return { score, pulse, signals };
}

View File

@@ -785,20 +785,29 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Trigger berth rules
void evaluateRule('eoi_sent', interest.id, portId, meta);
// Advance pipeline stage to eoi_sent (no-op if already further along).
void advanceStageIfBehind(interest.id, portId, 'eoi_sent', meta, 'EOI sent for signing');
// Advance pipeline stage to eoi (no-op if already further along).
// Doc sub-status is set by the webhook receiver when Documenso confirms;
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
void advanceStageIfBehind(interest.id, portId, 'eoi', meta, 'EOI sent for signing');
await db
.update(interests)
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
// G-C5: reservation agreements drive the contract_sent stage. The EOI
// and contract flows share `sendForSigning`, so we differentiate by
// documentType here rather than splitting the entry point.
// Reservation agreements drive the reservation stage; the contract
// pathway uses its own send call and stamps contractDocStatus.
if (doc.documentType === 'reservation_agreement') {
void advanceStageIfBehind(
interest.id,
portId,
'contract_sent',
'reservation',
meta,
'Reservation agreement sent',
);
await db
.update(interests)
.set({ reservationDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
}
}
@@ -888,17 +897,22 @@ export async function uploadSignedManually(
await db
.update(interests)
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
.set({
eoiStatus: 'signed',
eoiDocStatus: 'signed',
dateEoiSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
if (interest) {
void evaluateRule('eoi_signed', doc.interestId, portId, meta);
// Advance to eoi_signed (no-op if already past it).
// Stage stays at 'eoi' — sub-status badge flips to "signed".
void advanceStageIfBehind(
doc.interestId,
portId,
'eoi_signed',
'eoi',
meta,
'Signed EOI uploaded manually',
);
@@ -1412,30 +1426,6 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
ipAddress: '0.0.0.0',
userAgent: 'webhook',
});
// G-C5: reservation agreement signing-complete → contract_signed.
// Fired here (not below in the eoi-only branch) so contract pipeline
// tracks reality the same way EOIs do via the eoi_signed advance.
if (doc.documentType === 'reservation_agreement' && doc.interestId) {
const systemMeta: AuditMeta = {
userId: 'system',
portId: doc.portId,
ipAddress: '0.0.0.0',
userAgent: 'webhook',
};
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'contract_signed',
systemMeta,
'Reservation agreement signed',
);
// Dynamic import mirrors the eoi_signed pattern below to avoid the
// berth-rules-engine module-cycle risk during cold-start.
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
);
}
} catch (err) {
// Distinguish "we lost the concurrent race" from a real failure —
// the loser of the SELECT FOR UPDATE re-check should clean up its
@@ -1486,7 +1476,12 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
await db
.update(interests)
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
.set({
eoiStatus: 'signed',
eoiDocStatus: 'signed',
dateEoiSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
if (interest) {
@@ -1497,30 +1492,89 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
userAgent: 'webhook',
};
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple times
// (webhook retries) or follow a DOCUMENT_SIGNED that already advanced the
// stage. advanceStageIfBehind handles the pipeline guard internally, but
// evaluateRule has no idempotency - skip it if the interest is already at
// eoi_signed or beyond to prevent duplicate berth-rule side effects.
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple
// times. evaluateRule has no idempotency — skip when the interest is
// already past the EOI stage so the berth-rule side effect runs once.
const currentStageIdx = PIPELINE_STAGES.indexOf(
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
if (currentStageIdx < eoiSignedIdx) {
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
if (currentStageIdx <= eoiIdx) {
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
}
// Advance to eoi_signed (no-op if interest already past it).
// Stage stays at 'eoi' — sub-status flips to signed.
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'eoi_signed',
'eoi',
systemMeta,
'EOI signed via Documenso',
);
}
}
// Update interest if reservation_agreement type — kept out of the
// signed-PDF try/catch above so a Documenso PDF-download failure doesn't
// also lose the sub-status stamp (which the rep can see immediately on
// the interest detail page).
if (doc.interestId && doc.documentType === 'reservation_agreement') {
const systemMeta: AuditMeta = {
userId: 'system',
portId: doc.portId,
ipAddress: '0.0.0.0',
userAgent: 'webhook',
};
await db
.update(interests)
.set({
reservationDocStatus: 'signed',
dateReservationSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'reservation',
systemMeta,
'Reservation agreement signed',
);
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
);
}
// Update interest if contract type. Outcome flip to 'won' is a separate
// explicit decision so reps can record a contract as signed without
// prematurely closing the deal.
if (doc.interestId && doc.documentType === 'contract') {
const systemMeta: AuditMeta = {
userId: 'system',
portId: doc.portId,
ipAddress: '0.0.0.0',
userAgent: 'webhook',
};
await db
.update(interests)
.set({
contractDocStatus: 'signed',
dateContractSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'contract',
systemMeta,
'Contract signed via Documenso',
);
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
);
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'completed',

View File

@@ -16,7 +16,7 @@
* - is_in_eoi_bundle : covered by the interest's EOI signature.
*/
import { and, desc, eq, inArray } from 'drizzle-orm';
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
@@ -289,6 +289,25 @@ export async function upsertInterestBerthTx(
set: setForUpdate,
})
.returning();
// Auto-promote leadCategory: linking a specific berth means the interest
// is now anchored to a real piece of inventory, which is the definition
// of `specific_qualified`. Only bumps `general_interest` (or null) —
// never demotes `hot_lead` or anything else already past qualified.
const isSpecific = row?.isSpecificInterest ?? opts.isSpecificInterest ?? true;
if (isSpecific) {
await tx
.update(interests)
.set({ leadCategory: 'specific_qualified' })
.where(
and(eq(interests.id, interestId), inArray(interests.leadCategory, ['general_interest'])),
);
// Separately handle the NULL case (Drizzle's `inArray` can't include null).
await tx.execute(
sql`UPDATE interests SET lead_category = 'specific_qualified' WHERE id = ${interestId} AND lead_category IS NULL`,
);
}
return row!;
}

View File

@@ -29,12 +29,16 @@ import { ConflictError, NotFoundError } from '@/lib/errors';
export type ContactChannel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
export type ContactDirection = 'outbound' | 'inbound';
export type ContactTemplate = 'call' | 'visit' | 'email';
export interface CreateContactLogInput {
interestId: string;
occurredAt: Date;
channel: ContactChannel;
direction: ContactDirection;
summary: string;
voiceTranscript?: string | null;
templateUsed?: ContactTemplate | null;
followUpAt?: Date | null;
}
@@ -43,6 +47,8 @@ export interface UpdateContactLogInput {
channel?: ContactChannel;
direction?: ContactDirection;
summary?: string;
voiceTranscript?: string | null;
templateUsed?: ContactTemplate | null;
followUpAt?: Date | null;
}
@@ -114,6 +120,8 @@ export async function create(
channel: input.channel,
direction: input.direction,
summary: input.summary,
voiceTranscript: input.voiceTranscript ?? null,
templateUsed: input.templateUsed ?? null,
followUpAt: input.followUpAt ?? null,
reminderId,
createdBy: userId,
@@ -199,6 +207,8 @@ export async function update(
...(input.channel !== undefined && { channel: input.channel }),
...(input.direction !== undefined && { direction: input.direction }),
...(input.summary !== undefined && { summary: input.summary }),
...(input.voiceTranscript !== undefined && { voiceTranscript: input.voiceTranscript }),
...(input.templateUsed !== undefined && { templateUsed: input.templateUsed }),
followUpAt: newFollowUpAt,
reminderId,
updatedAt: new Date(),

View File

@@ -2,14 +2,16 @@ import { and, desc, eq, exists, inArray, isNull, ne, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
import { reminders } from '@/lib/db/schema/operations';
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { tags } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { getPortReminderConfig } from '@/lib/services/port-config';
import { getSetting } from '@/lib/services/settings.service';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
@@ -540,6 +542,32 @@ export async function getInterestById(id: string, portId: string) {
.from(reminders)
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
// Activity log entries in the last 7 days — surfaces "rep is engaged"
// as a separate signal in the deal-health pulse beyond the coarse
// dateLastContact bump.
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000);
const [{ count: recentActivityCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interestContactLog)
.where(
and(
eq(interestContactLog.interestId, id),
sql`${interestContactLog.occurredAt} >= ${sevenDaysAgo}`,
),
);
// Resolve the assignee's display name for the header chip — falling back
// to the raw ID is fine if the user record is missing (deleted/disabled).
let assignedToName: string | null = null;
if (interest.assignedTo) {
const [profile] = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, interest.assignedTo))
.limit(1);
assignedToName = profile?.displayName ?? null;
}
return {
...interest,
clientName: clientRow?.fullName ?? null,
@@ -554,6 +582,8 @@ export async function getInterestById(id: string, portId: string) {
notesCount,
recentNote: recentNote ?? null,
activeReminderCount,
assignedToName,
recentActivityCount,
};
}
@@ -586,12 +616,23 @@ export async function createInterest(portId: string, data: CreateInterestInput,
const resolvedReminderDays =
interestData.reminderDays ?? (resolvedReminderEnabled ? reminderConfig.defaultDays : null);
// Auto-assign to the port's default owner when the caller omits assignedTo.
// Setting is stored as `{ userId: "..." }` so other surfaces can extend it
// with round-robin / quota rules later without breaking this code path.
let resolvedAssignedTo = interestData.assignedTo ?? null;
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
const defaultOwner = await getSetting('default_new_interest_owner', portId);
const v = defaultOwner?.value as { userId?: string } | null | undefined;
if (v?.userId) resolvedAssignedTo = v.userId;
}
const result = await withTransaction(async (tx) => {
const [interest] = await tx
.insert(interests)
.values({
portId,
...interestData,
assignedTo: resolvedAssignedTo,
reminderEnabled: resolvedReminderEnabled,
reminderDays: resolvedReminderDays,
leadCategory: resolvedLeadCategory,
@@ -734,6 +775,36 @@ export async function updateInterest(
changedFields: Object.keys(diff),
});
// Owner change → notify the new assignee. We skip self-reassign so a rep
// re-claiming their own deal doesn't get a noise notification.
if (
'assignedTo' in data &&
data.assignedTo &&
data.assignedTo !== existing.assignedTo &&
data.assignedTo !== meta.userId
) {
const [clientRow] = await db
.select({ fullName: clients.fullName })
.from(clients)
.where(eq(clients.id, existing.clientId))
.limit(1);
const clientLabel = clientRow?.fullName ?? 'a client';
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
createNotification({
portId,
userId: data.assignedTo!,
type: 'interest_assigned',
title: 'New deal assigned to you',
description: `${clientLabel}${existing.pipelineStage.replace(/_/g, ' ')}`,
link: `/interests/${id}` as never,
entityType: 'interest',
entityId: id,
dedupeKey: `interest_assigned:${id}:${data.assignedTo}`,
}),
);
}
return updated!;
}
@@ -753,14 +824,18 @@ export async function changeInterestStage(
throw new NotFoundError('Interest');
}
// Plan: yachtId required to leave stage=open
if (existing.pipelineStage === 'open' && data.pipelineStage !== 'open' && !existing.yachtId) {
throw new ValidationError('yachtId is required before leaving stage=open');
// Plan: yachtId required to leave the initial enquiry stage
if (
existing.pipelineStage === 'enquiry' &&
data.pipelineStage !== 'enquiry' &&
!existing.yachtId
) {
throw new ValidationError('yachtId is required before leaving stage=enquiry');
}
// Block egregious skips. The transition table allows reasonable forward
// jumps (e.g. open → eoi_sent) while rejecting things like completed → open
// or open → contract_signed. Same-stage no-ops are allowed.
// jumps (e.g. enquiry → eoi) while rejecting things like contract → enquiry.
// Same-stage no-ops are allowed.
// Override (sales-rep manual fix) bypasses the table — the route handler
// gates this on the `interests.override_stage` permission and requires
// a reason, recorded in the audit log below.
@@ -788,11 +863,13 @@ export async function changeInterestStage(
// "deposit landed yesterday"); we still default to now when omitted.
const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
const milestoneUpdates: Record<string, unknown> = {};
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = milestoneDate;
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = milestoneDate;
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = milestoneDate;
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = milestoneDate;
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = milestoneDate;
// For doc-bearing stages (eoi/reservation/contract) the milestone date is
// owned by the doc-send/sign flow, not the stage move — these only fire
// when the rep stamps a date manually via override.
if (data.pipelineStage === 'eoi') milestoneUpdates.dateEoiSent = milestoneDate;
if (data.pipelineStage === 'reservation') milestoneUpdates.dateReservationSigned = milestoneDate;
if (data.pipelineStage === 'deposit_paid') milestoneUpdates.dateDepositReceived = milestoneDate;
if (data.pipelineStage === 'contract') milestoneUpdates.dateContractSent = milestoneDate;
if (Object.keys(milestoneUpdates).length > 0) {
await db
.update(interests)

View File

@@ -675,13 +675,16 @@ export async function recordPayment(
// Deposit invoices linked to a sales interest auto-advance the pipeline.
// Only advances forward - no-op if the interest has already moved past
// deposit_10pct (e.g. straight-to-contract flows).
// deposit_paid (e.g. straight-to-contract flows). NOTE: the v1 sales
// refactor introduces a separate `payments` table that supersedes invoice
// tracking for the deposit stage; this block stays wired for legacy
// invoices but new flows record payments via that pathway instead.
if (updated.kind === 'deposit' && updated.interestId) {
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
void advanceStageIfBehind(
updated.interestId,
portId,
'deposit_10pct',
'deposit_paid',
meta,
`Deposit invoice ${existing.invoiceNumber} paid`,
);

View File

@@ -0,0 +1,226 @@
/**
* Payment-records service. The CRM does NOT generate invoices — banks invoice
* clients directly. We record that money was received (or refunded) with an
* optional uploaded receipt for audit purposes.
*
* Auto-advance: when the running deposit total (SUM where payment_type='deposit'
* minus SUM of refunds) reaches `interests.depositExpectedAmount`, the pipeline
* stage moves to 'deposit_paid' (no-op if already past).
*/
import { and, asc, desc, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, payments } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type { CreatePaymentInput, UpdatePaymentInput } from '@/lib/validators/payments';
// ─── Reads ──────────────────────────────────────────────────────────────────
/** All payments for a single interest, newest received first. */
export async function listPaymentsForInterest(interestId: string, portId: string) {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
columns: { id: true },
});
if (!interest) throw new NotFoundError('Interest');
return db
.select()
.from(payments)
.where(and(eq(payments.interestId, interestId), eq(payments.portId, portId)))
.orderBy(desc(payments.receivedAt), asc(payments.id));
}
/** Net deposit total for an interest. `deposit` rows add; `refund` rows
* subtract (their `amount` may be either positive or already negative — we
* always treat refunds as deductions to match the UI convention). */
export async function getDepositTotalForInterest(
interestId: string,
portId: string,
): Promise<{ total: string; currency: string }> {
const rows = await db
.select({
paymentType: payments.paymentType,
amount: payments.amount,
currency: payments.currency,
})
.from(payments)
.where(
and(
eq(payments.interestId, interestId),
eq(payments.portId, portId),
sql`${payments.paymentType} IN ('deposit', 'refund')`,
),
);
// Use BigInt-ish accumulator via Number — amounts are EUR scale; we don't
// need cent-precise math for the auto-advance gate, but we DO normalize the
// sign of refunds so a refund stored as "+200" still subtracts.
let net = 0;
let currency = 'EUR';
for (const row of rows) {
const n = Number(row.amount);
if (!Number.isFinite(n)) continue;
currency = row.currency;
net += row.paymentType === 'refund' ? -Math.abs(n) : n;
}
return { total: net.toFixed(2), currency };
}
// ─── Writes ─────────────────────────────────────────────────────────────────
export async function createPayment(portId: string, data: CreatePaymentInput, meta: AuditMeta) {
// Resolve interest + sanity-check it belongs to this port.
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, data.interestId), eq(interests.portId, portId)),
columns: { id: true, clientId: true, depositExpectedAmount: true, pipelineStage: true },
});
if (!interest) throw new NotFoundError('Interest');
const amountNum = Number(data.amount);
if (!Number.isFinite(amountNum) || amountNum === 0) {
throw new ValidationError('amount must be a non-zero numeric value');
}
const [row] = await db
.insert(payments)
.values({
portId,
interestId: data.interestId,
clientId: interest.clientId,
paymentType: data.paymentType,
amount: data.amount,
currency: data.currency,
receivedAt: new Date(data.receivedAt),
receiptFileId: data.receiptFileId ?? null,
notes: data.notes ?? null,
recordedBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'payment',
entityId: row!.id,
newValue: {
interestId: data.interestId,
paymentType: data.paymentType,
amount: data.amount,
currency: data.currency,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'payment:created', {
paymentId: row!.id,
interestId: data.interestId,
paymentType: data.paymentType,
});
// Auto-advance: when the running deposit total reaches the expected amount,
// promote the stage to 'deposit_paid'. Dynamic import keeps the
// payments ↔ interests cycle one-way at module-load time.
if (data.paymentType === 'deposit' || data.paymentType === 'refund') {
const { total } = await getDepositTotalForInterest(data.interestId, portId);
const expected = interest.depositExpectedAmount ? Number(interest.depositExpectedAmount) : null;
if (expected !== null && Number.isFinite(expected) && Number(total) >= expected) {
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
void advanceStageIfBehind(
data.interestId,
portId,
'deposit_paid',
meta,
`Deposit total (${total} ${data.currency}) reached expected amount`,
);
// Stamp dateDepositReceived if not already set so the timeline shows
// when the threshold was met (not the date of the first payment row).
await db
.update(interests)
.set({ dateDepositReceived: new Date(), updatedAt: new Date() })
.where(eq(interests.id, data.interestId));
// Berth rule fires via the same hook the legacy invoices.ts path uses.
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
void evaluateRule('deposit_received', data.interestId, portId, meta);
}
}
return row!;
}
export async function updatePayment(
id: string,
portId: string,
data: UpdatePaymentInput,
meta: AuditMeta,
) {
const existing = await db.query.payments.findFirst({
where: and(eq(payments.id, id), eq(payments.portId, portId)),
});
if (!existing) throw new NotFoundError('Payment');
const next: Record<string, unknown> = {};
if (data.paymentType !== undefined) next.paymentType = data.paymentType;
if (data.amount !== undefined) next.amount = data.amount;
if (data.currency !== undefined) next.currency = data.currency;
if (data.receivedAt !== undefined) next.receivedAt = new Date(data.receivedAt);
if (data.receiptFileId !== undefined) next.receiptFileId = data.receiptFileId;
if (data.notes !== undefined) next.notes = data.notes;
const [updated] = await db
.update(payments)
.set(next)
.where(and(eq(payments.id, id), eq(payments.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'payment',
entityId: id,
oldValue: {
paymentType: existing.paymentType,
amount: existing.amount,
currency: existing.currency,
},
newValue: next,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return updated!;
}
export async function deletePayment(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.payments.findFirst({
where: and(eq(payments.id, id), eq(payments.portId, portId)),
});
if (!existing) throw new NotFoundError('Payment');
await db.delete(payments).where(and(eq(payments.id, id), eq(payments.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'payment',
entityId: id,
oldValue: {
paymentType: existing.paymentType,
amount: existing.amount,
currency: existing.currency,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return { ok: true };
}

View File

@@ -0,0 +1,278 @@
/**
* Qualification-criteria service. Per-port admins configure the criteria that
* a deal must satisfy to be considered "qualified" (the gate between enquiry
* and the rest of the pipeline). Per-interest state is captured separately
* so changing the port's criteria doesn't retroactively affect existing
* deals.
*
* The "fully qualified" derivation drives the soft hint on the interest
* detail page that an enquiry is ready to advance.
*/
import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import type {
CreateQualificationCriterionInput,
SetInterestQualificationInput,
UpdateQualificationCriterionInput,
} from '@/lib/validators/qualification';
// ─── Port-scoped criterion config (admin) ───────────────────────────────────
export async function listCriteriaForPort(portId: string) {
return db
.select()
.from(qualificationCriteria)
.where(eq(qualificationCriteria.portId, portId))
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
}
export async function createCriterion(
portId: string,
data: CreateQualificationCriterionInput,
meta: AuditMeta,
) {
// Unique (portId, key) is enforced at DB level, but doing the check here
// surfaces a friendlier 409 with the offending key.
const existing = await db.query.qualificationCriteria.findFirst({
where: and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.key, data.key)),
});
if (existing) {
throw new ConflictError(`A criterion with key "${data.key}" already exists for this port`);
}
const [row] = await db
.insert(qualificationCriteria)
.values({
portId,
key: data.key,
label: data.label,
description: data.description ?? null,
enabled: data.enabled,
displayOrder: data.displayOrder,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'qualification_criterion',
entityId: row!.id,
newValue: { key: data.key, label: data.label, enabled: data.enabled },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return row!;
}
export async function updateCriterion(
id: string,
portId: string,
data: UpdateQualificationCriterionInput,
meta: AuditMeta,
) {
const existing = await db.query.qualificationCriteria.findFirst({
where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
});
if (!existing) throw new NotFoundError('Qualification criterion');
const next: Record<string, unknown> = { updatedAt: new Date() };
if (data.label !== undefined) next.label = data.label;
if (data.description !== undefined) next.description = data.description;
if (data.enabled !== undefined) next.enabled = data.enabled;
if (data.displayOrder !== undefined) next.displayOrder = data.displayOrder;
const [updated] = await db
.update(qualificationCriteria)
.set(next)
.where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'qualification_criterion',
entityId: id,
oldValue: { label: existing.label, enabled: existing.enabled },
newValue: next,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return updated!;
}
export async function deleteCriterion(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.qualificationCriteria.findFirst({
where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
});
if (!existing) throw new NotFoundError('Qualification criterion');
// Per-interest state rows reference the key, not the criterion id, so they
// survive a criterion deletion as historical noise. UI hides rows whose key
// no longer matches an active criterion.
await db
.delete(qualificationCriteria)
.where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'qualification_criterion',
entityId: id,
oldValue: { key: existing.key, label: existing.label },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return { ok: true };
}
// ─── Per-interest state ─────────────────────────────────────────────────────
export interface QualificationRow {
key: string;
label: string;
description: string | null;
enabled: boolean;
displayOrder: number;
confirmed: boolean;
confirmedAt: Date | null;
confirmedBy: string | null;
notes: string | null;
}
/**
* The qualification state for a specific interest, joined with the port's
* current criterion definitions. Returns only currently-enabled criteria —
* disabled ones are hidden from the rep but their state rows are preserved
* in the DB for audit.
*/
export async function listInterestQualifications(
interestId: string,
portId: string,
): Promise<QualificationRow[]> {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
columns: { id: true },
});
if (!interest) throw new NotFoundError('Interest');
const criteria = await db
.select()
.from(qualificationCriteria)
.where(and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.enabled, true)))
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
const states = await db
.select()
.from(interestQualifications)
.where(eq(interestQualifications.interestId, interestId));
const stateByKey = new Map(states.map((s) => [s.criterionKey, s] as const));
return criteria.map((c) => {
const s = stateByKey.get(c.key);
return {
key: c.key,
label: c.label,
description: c.description,
enabled: c.enabled,
displayOrder: c.displayOrder,
confirmed: s?.confirmed ?? false,
confirmedAt: s?.confirmedAt ?? null,
confirmedBy: s?.confirmedBy ?? null,
notes: s?.notes ?? null,
};
});
}
/**
* Upsert a single criterion's confirmed-state for an interest. Stamping the
* server-side fields (confirmedBy / confirmedAt) makes the row a proper
* audit record — the caller can't backdate it.
*/
export async function setInterestQualification(
interestId: string,
portId: string,
data: SetInterestQualificationInput,
meta: AuditMeta,
) {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
columns: { id: true },
});
if (!interest) throw new NotFoundError('Interest');
// Refuse keys the port doesn't have a criterion for — keeps state rows
// referentially consistent with the visible config.
const criterion = await db.query.qualificationCriteria.findFirst({
where: and(
eq(qualificationCriteria.portId, portId),
eq(qualificationCriteria.key, data.criterionKey),
),
});
if (!criterion) throw new NotFoundError('Qualification criterion');
const now = new Date();
await db
.insert(interestQualifications)
.values({
interestId,
criterionKey: data.criterionKey,
confirmed: data.confirmed,
confirmedAt: data.confirmed ? now : null,
confirmedBy: data.confirmed ? meta.userId : null,
notes: data.notes ?? null,
})
.onConflictDoUpdate({
target: [interestQualifications.interestId, interestQualifications.criterionKey],
set: {
confirmed: data.confirmed,
confirmedAt: data.confirmed ? now : null,
confirmedBy: data.confirmed ? meta.userId : null,
notes: data.notes ?? null,
},
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest_qualification',
entityId: `${interestId}:${data.criterionKey}`,
newValue: { confirmed: data.confirmed, key: data.criterionKey },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:qualificationChanged', {
interestId,
criterionKey: data.criterionKey,
confirmed: data.confirmed,
});
return listInterestQualifications(interestId, portId);
}
/**
* Returns true when every enabled criterion for the port is confirmed for
* the given interest. Used by the UI to surface the "ready to qualify" hint
* and by the auto-advance helper to soft-suggest moving to 'qualified'.
*/
export async function isInterestFullyQualified(
interestId: string,
portId: string,
): Promise<boolean> {
const rows = await listInterestQualifications(interestId, portId);
if (rows.length === 0) return false;
return rows.every((r) => r.confirmed);
}

View File

@@ -113,13 +113,13 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
keywords: ['labels', 'categories', 'classification'],
},
{
href: '/:portSlug/settings/profile',
href: '/:portSlug/settings',
label: 'Notification preferences',
category: 'settings',
keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'],
},
{
href: '/:portSlug/settings/profile',
href: '/:portSlug/settings',
label: 'My profile & preferences',
category: 'settings',
keywords: [

View File

@@ -68,6 +68,8 @@ export interface ClientResult {
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
/** Short label for which field matched ("name", "email", "phone", "trigram", "expansion"). Used by the dropdown to render "matched on X". */
matchedOn?: string | null;
}
export interface ResidentialClientResult {
@@ -451,13 +453,25 @@ async function searchClients(
LIMIT ${limit}
`);
return Array.from(rows).map((r) => ({
id: r.id,
fullName: r.full_name,
matchedContact: r.matched_value ?? null,
matchedContactChannel: r.matched_channel ?? null,
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
}));
return Array.from(rows).map((r) => {
// Tag the rank tier we picked back as a human-readable label so the
// dropdown can render "matched on name" / "matched on email" without
// the UI re-doing the comparison. Mirrors the CASE in `rank` above.
let matchedOn: string | null = null;
if (r.rank >= 80) matchedOn = 'name';
else if (r.rank >= 70) matchedOn = 'name';
else if (r.rank >= 60) matchedOn = r.matched_channel ?? 'contact';
else if (r.rank >= 55) matchedOn = 'phone';
else if (r.rank >= 30) matchedOn = 'similar name';
return {
id: r.id,
fullName: r.full_name,
matchedContact: r.matched_value ?? null,
matchedContactChannel: r.matched_channel ?? null,
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
matchedOn,
};
});
}
async function searchResidentialClients(
@@ -1870,17 +1884,38 @@ export async function search(
// who search "A10" see the linked interests/clients/yachts/companies
// surface alongside the berth. See `expandGraph` docstring for the
// relationship map and per-bucket caps.
const expanded = await expandGraph(
portId,
{
berthIds: berths.map((b) => b.id),
interestIds: interests.map((i) => i.id),
clientIds: clients.map((c) => c.id),
yachtIds: yachts.map((y) => y.id),
companyIds: companies.map((c) => c.id),
},
limit,
);
//
// Latency optimization: when every relationship-bearing bucket already
// has the maximum number of direct matches the dropdown will render,
// graph expansion only adds rows that get truncated downstream — skip
// the (cross-table-heavy) expansion query entirely. Saves the biggest
// single SQL call in the search path on common-term queries.
const allBucketsFull =
clients.length >= limit &&
yachts.length >= limit &&
companies.length >= limit &&
interests.length >= limit &&
berths.length >= limit;
const expanded = allBucketsFull
? {
interests: [] as InterestResult[],
clients: [] as ClientResult[],
yachts: [] as YachtResult[],
companies: [] as CompanyResult[],
berths: [] as BerthResult[],
}
: await expandGraph(
portId,
{
berthIds: berths.map((b) => b.id),
interestIds: interests.map((i) => i.id),
clientIds: clients.map((c) => c.id),
yachtIds: yachts.map((y) => y.id),
companyIds: companies.map((c) => c.id),
},
limit,
);
const apply = <T extends { id: string }>(rows: T[]) =>
applyAffinity(rows, opts.recentlyTouchedIds);

View File

@@ -86,6 +86,11 @@ export interface ServerToClientEvents {
'residential_interest:created': (payload: { id: string }) => void;
'residential_interest:updated': (payload: { id: string }) => void;
'residential_interest:archived': (payload: { id: string }) => void;
'interest:qualificationChanged': (payload: {
interestId: string;
criterionKey: string;
confirmed: boolean;
}) => void;
'interest:leadCategoryChanged': (payload: {
interestId: string;
oldCategory: string;
@@ -200,6 +205,13 @@ export interface ServerToClientEvents {
invoiceNumber: string;
daysPastDue: number;
}) => void;
'payment:created': (payload: {
paymentId: string;
interestId: string;
paymentType: string;
}) => void;
'payment:updated': (payload: { paymentId: string; interestId: string }) => void;
'payment:deleted': (payload: { paymentId: string; interestId: string }) => void;
// Reminder & Calendar events
'reminder:created': (payload: {

View File

@@ -18,6 +18,14 @@ export const SUPPORTED_CURRENCIES = [
{ code: 'AED', symbol: 'د.إ', label: 'UAE Dirham' },
{ code: 'SGD', symbol: 'S$', label: 'Singapore Dollar' },
{ code: 'HKD', symbol: 'HK$', label: 'Hong Kong Dollar' },
{ code: 'PAB', symbol: 'B/.', label: 'Panamanian Balboa' },
{ code: 'XCD', symbol: 'EC$', label: 'East Caribbean Dollar' },
{ code: 'BSD', symbol: 'B$', label: 'Bahamian Dollar' },
{ code: 'KYD', symbol: 'CI$', label: 'Cayman Islands Dollar' },
{ code: 'BBD', symbol: 'Bds$', label: 'Barbadian Dollar' },
{ code: 'DOP', symbol: 'RD$', label: 'Dominican Peso' },
{ code: 'JMD', symbol: 'J$', label: 'Jamaican Dollar' },
{ code: 'TTD', symbol: 'TT$', label: 'Trinidad & Tobago Dollar' },
] as const;
export type SupportedCurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]['code'];

View File

@@ -5,12 +5,20 @@ const DIRECTIONS = ['outbound', 'inbound'] as const;
/** Cap summary length so a rep can't accidentally paste a 10MB email body. */
const SUMMARY_MAX = 4000;
/** Voice transcripts can run long for meetings; cap a bit higher than summary. */
const TRANSCRIPT_MAX = 20_000;
const TEMPLATES = ['call', 'visit', 'email'] as const;
export const createContactLogSchema = z.object({
occurredAt: z.coerce.date(),
channel: z.enum(CHANNELS),
direction: z.enum(DIRECTIONS).default('outbound'),
summary: z.string().min(1).max(SUMMARY_MAX),
/** Raw Web Speech transcript, optional. Kept verbatim alongside the
* rep-polished summary so an edit to summary doesn't lose the original. */
voiceTranscript: z.string().max(TRANSCRIPT_MAX).optional().nullable(),
/** Which quick-template button (if any) seeded this entry. */
templateUsed: z.enum(TEMPLATES).optional().nullable(),
followUpAt: z.coerce.date().optional().nullable(),
});
@@ -20,6 +28,8 @@ export const updateContactLogSchema = z
channel: z.enum(CHANNELS),
direction: z.enum(DIRECTIONS),
summary: z.string().min(1).max(SUMMARY_MAX),
voiceTranscript: z.string().max(TRANSCRIPT_MAX).nullable(),
templateUsed: z.enum(TEMPLATES).nullable(),
followUpAt: z.coerce.date().nullable(),
})
.partial();

View File

@@ -36,7 +36,36 @@ export const createInterestSchema = z.object({
clientId: z.string().min(1),
yachtId: z.string().optional(),
berthId: z.string().optional(),
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
/** Sales rep who owns this deal. Empty string treated as "unassign";
* omitting the field leaves the current assignment unchanged. On create,
* omitting falls back to system_settings.default_new_interest_owner. */
assignedTo: z.string().nullable().optional(),
/** Captured at reservation-agreement time. Drives the deposit-paid
* auto-advance once payment totals catch up. */
depositExpectedAmount: z.string().optional().nullable(),
depositExpectedCurrency: z.string().length(3).optional(),
/** Doc sub-status badges. Stamped automatically by the Documenso webhook
* + custom-upload pathway; exposed via the update endpoint so reps can
* "Mark signed manually" from the milestone strip when a doc was signed
* outside the Documenso flow (e.g. an in-person paper signing). */
eoiDocStatus: z.enum(['pending', 'sent', 'signed', 'declined', 'voided']).nullable().optional(),
reservationDocStatus: z
.enum(['pending', 'sent', 'signed', 'declined', 'voided'])
.nullable()
.optional(),
contractDocStatus: z
.enum(['pending', 'sent', 'signed', 'declined', 'voided'])
.nullable()
.optional(),
/** Milestone dates exposed for manual stamping via PATCH; auto-stamped
* by the signing flows when reps use the Documenso pathway. Coerced
* to a Date so Drizzle gets the right type for the timestamptz column. */
dateEoiSent: z.coerce.date().nullable().optional(),
dateEoiSigned: z.coerce.date().nullable().optional(),
dateReservationSigned: z.coerce.date().nullable().optional(),
dateContractSent: z.coerce.date().nullable().optional(),
dateContractSigned: z.coerce.date().nullable().optional(),
pipelineStage: z.enum(PIPELINE_STAGES).default('enquiry'),
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
source: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
export const PAYMENT_TYPES = ['deposit', 'balance', 'refund', 'other'] as const;
export type PaymentType = (typeof PAYMENT_TYPES)[number];
const decimalString = z
.string()
.min(1)
.regex(/^-?\d+(\.\d+)?$/, 'Must be a numeric string (e.g. "1500" or "1500.00")');
export const createPaymentSchema = z.object({
interestId: z.string().min(1),
paymentType: z.enum(PAYMENT_TYPES),
amount: decimalString,
currency: z.string().length(3).default('EUR'),
receivedAt: z
.string()
.min(1)
.refine((v) => !Number.isNaN(Date.parse(v)), 'Must be a parseable date/datetime string'),
receiptFileId: z.string().optional().nullable(),
notes: z.string().optional().nullable(),
});
export const updatePaymentSchema = createPaymentSchema.omit({ interestId: true }).partial();
export type CreatePaymentInput = z.infer<typeof createPaymentSchema>;
export type UpdatePaymentInput = z.infer<typeof updatePaymentSchema>;

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
/**
* Per-port qualification criterion. Admin-configurable: label / description /
* enabled state / display order. The `key` is the stable identifier code
* references (templates, derivations) — it can't be changed after creation
* because per-interest state rows reference it via composite PK.
*/
export const createQualificationCriterionSchema = z.object({
key: z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, 'Must be lowercase alphanumeric with underscores'),
label: z.string().min(1).max(120),
description: z.string().max(500).optional().nullable(),
enabled: z.boolean().default(true),
displayOrder: z.number().int().min(0).default(0),
});
export const updateQualificationCriterionSchema = createQualificationCriterionSchema
.omit({ key: true })
.partial();
/**
* Per-interest qualification state. Only `confirmed` + optional `notes` are
* writable — `confirmedAt` / `confirmedBy` are stamped server-side from
* the auth context.
*/
export const setInterestQualificationSchema = z.object({
criterionKey: z.string().min(1),
confirmed: z.boolean(),
notes: z.string().max(500).optional().nullable(),
});
export type CreateQualificationCriterionInput = z.infer<typeof createQualificationCriterionSchema>;
export type UpdateQualificationCriterionInput = z.infer<typeof updateQualificationCriterionSchema>;
export type SetInterestQualificationInput = z.infer<typeof setInterestQualificationSchema>;

View File

@@ -649,19 +649,17 @@ export function makeCreateClientInput(overrides?: { fullName?: string; portId?:
export function makeCreateInterestInput(overrides?: {
clientId?: string;
pipelineStage?:
| 'open'
| 'details_sent'
| 'in_communication'
| 'eoi_sent'
| 'eoi_signed'
| 'deposit_10pct'
| 'contract_sent'
| 'contract_signed'
| 'completed';
| 'enquiry'
| 'qualified'
| 'nurturing'
| 'eoi'
| 'reservation'
| 'deposit_paid'
| 'contract';
}) {
return {
clientId: overrides?.clientId ?? crypto.randomUUID(),
pipelineStage: overrides?.pipelineStage ?? ('open' as const),
pipelineStage: overrides?.pipelineStage ?? ('enquiry' as const),
reminderEnabled: false,
tagIds: [] as string[],
};

Some files were not shown because too many files have changed in this diff Show More