From ba5fb6db5ec8a2f477c6f3673849b2a3205d51e3 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sat, 2 May 2026 00:01:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(sales):=20EOI=20queue=20route=20+=20invoic?= =?UTF-8?q?e=E2=86=92deposit=20auto-advance=20+=20won/lost=20outcomes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- .../[portSlug]/documents/eoi/page.tsx | 10 + .../[portSlug]/invoices/new/page.tsx | 60 +- .../api/v1/interests/[id]/outcome/route.ts | 41 + src/components/documents/documents-hub.tsx | 5 +- .../interests/interest-detail-header.tsx | 266 +- .../interests/interest-outcome-dialog.tsx | 156 + src/components/interests/interest-tabs.tsx | 21 +- src/components/layout/sidebar.tsx | 2 + src/lib/db/migrations/0019_lazy_vampiro.sql | 8 + src/lib/db/migrations/meta/0019_snapshot.json | 10240 ++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema/financial.ts | 9 + src/lib/db/schema/interests.ts | 13 +- src/lib/services/analytics.service.ts | 37 +- src/lib/services/dashboard.service.ts | 14 +- src/lib/services/interests.service.ts | 106 + src/lib/services/invoices.ts | 60 +- src/lib/socket/events.ts | 6 + src/lib/validators/interests.ts | 24 + src/lib/validators/invoices.ts | 19 +- 21 files changed, 10995 insertions(+), 112 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/documents/eoi/page.tsx create mode 100644 src/app/api/v1/interests/[id]/outcome/route.ts create mode 100644 src/components/interests/interest-outcome-dialog.tsx create mode 100644 src/lib/db/migrations/0019_lazy_vampiro.sql create mode 100644 src/lib/db/migrations/meta/0019_snapshot.json diff --git a/.gitignore b/.gitignore index 69158e0..49f2dd2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,8 @@ tsconfig.tsbuildinfo docker-compose.override.yml .remember/ .DS_Store -eoi/ +# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match. +/eoi/ # Brainstorming companion mockup files .superpowers/ diff --git a/src/app/(dashboard)/[portSlug]/documents/eoi/page.tsx b/src/app/(dashboard)/[portSlug]/documents/eoi/page.tsx new file mode 100644 index 0000000..c49e825 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/documents/eoi/page.tsx @@ -0,0 +1,10 @@ +import { DocumentsHub } from '@/components/documents/documents-hub'; + +interface PageProps { + params: Promise<{ portSlug: string }>; +} + +export default async function EoiQueuePage({ params }: PageProps) { + const { portSlug } = await params; + return ; +} diff --git a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx index 62f55c3..01b4b65 100644 --- a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx +++ b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx @@ -1,11 +1,11 @@ 'use client'; import { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation } from '@tanstack/react-query'; -import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { ChevronLeft, ChevronRight, Check, Loader2, Wallet } from 'lucide-react'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; @@ -45,6 +45,10 @@ export default function NewInvoicePage() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const router = useRouter(); + const searchParams = useSearchParams(); + const prefilledInterestId = searchParams.get('interestId') ?? undefined; + const prefilledKind = + searchParams.get('kind') === 'deposit' ? ('deposit' as const) : ('general' as const); const [step, setStep] = useState(1); @@ -54,6 +58,22 @@ export default function NewInvoicePage() { return () => setChrome({ title: null, showBackButton: false }); }, [setChrome]); + // When the form is launched from an interest detail with `?interestId=…&kind=deposit`, + // fetch enough of the interest to display "Deposit for {client} — Berth {n}" in + // the review step. Doubles as the source of truth for the billing entity prefill. + const { data: prefilledInterest } = useQuery<{ + data: { + id: string; + clientId: string; + clientName: string | null; + berthMooringNumber: string | null; + }; + }>({ + queryKey: ['interest-prefill', prefilledInterestId], + queryFn: () => apiFetch(`/api/v1/interests/${prefilledInterestId}`), + enabled: !!prefilledInterestId, + }); + const methods = useForm({ resolver: zodResolver(createInvoiceSchema), defaultValues: { @@ -61,6 +81,8 @@ export default function NewInvoicePage() { currency: 'USD', lineItems: [], expenseIds: [], + interestId: prefilledInterestId, + kind: prefilledKind, }, }); @@ -73,6 +95,21 @@ export default function NewInvoicePage() { } = methods; const watchedValues = watch(); + const isDepositInvoice = watchedValues.kind === 'deposit'; + + // Pre-fill the billing entity from the linked interest's client on launch. + useEffect(() => { + if (prefilledInterest?.data && !watchedValues.billingEntity) { + setValue( + 'billingEntity', + { type: 'client', id: prefilledInterest.data.clientId }, + { shouldValidate: true }, + ); + } + // We only want this to run when the interest data first arrives. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prefilledInterest?.data?.clientId]); + const lineItems = watchedValues.lineItems ?? []; const subtotal = lineItems.reduce( (sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0), @@ -165,6 +202,23 @@ export default function NewInvoicePage() { Client Information + {isDepositInvoice ? ( +
+ +
+

Deposit invoice

+

+ {prefilledInterest?.data + ? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${ + prefilledInterest.data.berthMooringNumber + ? ` — Berth ${prefilledInterest.data.berthMooringNumber}` + : '' + }. Marking this invoice as paid will advance the interest to "Deposit 10%".` + : 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'} +

+
+
+ ) : null}