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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
40
src/app/api/v1/admin/qualification-criteria/[id]/route.ts
Normal file
40
src/app/api/v1/admin/qualification-criteria/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
33
src/app/api/v1/admin/qualification-criteria/route.ts
Normal file
33
src/app/api/v1/admin/qualification-criteria/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
51
src/app/api/v1/interests/[id]/payments/route.ts
Normal file
51
src/app/api/v1/interests/[id]/payments/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
44
src/app/api/v1/interests/[id]/qualifications/route.ts
Normal file
44
src/app/api/v1/interests/[id]/qualifications/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
40
src/app/api/v1/payments/[id]/route.ts
Normal file
40
src/app/api/v1/payments/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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 you’ve 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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
|
||||
341
src/components/admin/qualification-criteria-admin.tsx
Normal file
341
src/components/admin/qualification-criteria-admin.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 “Other…” to type any name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
31
src/components/icons/whatsapp.tsx
Normal file
31
src/components/icons/whatsapp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
124
src/components/interests/assigned-to-chip.tsx
Normal file
124
src/components/interests/assigned-to-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
78
src/components/interests/deal-pulse-chip.tsx
Normal file
78
src/components/interests/deal-pulse-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
76
src/components/interests/interest-berth-status-banner.tsx
Normal file
76
src/components/interests/interest-berth-status-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/interests/multi-eoi-chip.tsx
Normal file
47
src/components/interests/multi-eoi-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
377
src/components/interests/payments-section.tsx
Normal file
377
src/components/interests/payments-section.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
157
src/components/interests/qualification-checklist.tsx
Normal file
157
src/components/interests/qualification-checklist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/interests/skip-ahead-banner.tsx
Normal file
76
src/components/interests/skip-ahead-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/interests/supplemental-info-request-button.tsx
Normal file
102
src/components/interests/supplemental-info-request-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
211
src/components/interests/won-status-panel.tsx
Normal file
211
src/components/interests/won-status-panel.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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'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's
|
||||
deals.
|
||||
Linking a reminder pins it onto that record so anyone who opens the page sees it on
|
||||
the Reminders tab. Useful for “chase this client for signed EOI”,
|
||||
“recheck B12 power capacity before contract”, etc. Pick a client first to
|
||||
scope the interest and berth dropdowns to that client'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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
160
src/components/shared/user-picker.tsx
Normal file
160
src/components/shared/user-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
153
src/hooks/use-voice-transcription.ts
Normal file
153
src/hooks/use-voice-transcription.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 (0–1).
|
||||
// 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 {
|
||||
|
||||
139
src/lib/db/migrations/0062_pipeline_refactor.sql
Normal file
139
src/lib/db/migrations/0062_pipeline_refactor.sql
Normal 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);
|
||||
14
src/lib/db/migrations/0063_contact_log_voice_template.sql
Normal file
14
src/lib/db/migrations/0063_contact_log_voice_template.sql
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
125
src/lib/db/schema/pipeline.ts
Normal file
125
src/lib/db/schema/pipeline.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
212
src/lib/services/deal-health.ts
Normal file
212
src/lib/services/deal-health.ts
Normal 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 (0–100, 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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
226
src/lib/services/payments.service.ts
Normal file
226
src/lib/services/payments.service.ts
Normal 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 };
|
||||
}
|
||||
278
src/lib/services/qualification.service.ts
Normal file
278
src/lib/services/qualification.service.ts
Normal 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);
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
27
src/lib/validators/payments.ts
Normal file
27
src/lib/validators/payments.ts
Normal 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>;
|
||||
38
src/lib/validators/qualification.ts
Normal file
38
src/lib/validators/qualification.ts
Normal 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>;
|
||||
@@ -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
Reference in New Issue
Block a user