feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes

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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-02 00:01:33 +02:00
parent 886119cbde
commit ba5fb6db5e
21 changed files with 10995 additions and 112 deletions

View File

@@ -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 <DocumentsHub portSlug={portSlug} initialTab="eoi_queue" />;
}

View File

@@ -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<CreateInvoiceInput>({
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() {
<CardTitle className="text-base">Client Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isDepositInvoice ? (
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<Wallet className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Deposit invoice</p>
<p className="text-xs text-amber-800">
{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%".'}
</p>
</div>
</div>
) : null}
<div className="space-y-2">
<Label>
Billing entity <span className="text-destructive">*</span>

View File

@@ -0,0 +1,41 @@
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 { clearInterestOutcome, setInterestOutcome } from '@/lib/services/interests.service';
import { clearOutcomeSchema, setOutcomeSchema } from '@/lib/validators/interests';
export const POST = withAuth(
withPermission('interests', 'change_stage', async (req, ctx, params) => {
try {
const body = await parseBody(req, setOutcomeSchema);
const result = await setInterestOutcome(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('interests', 'change_stage', async (req, ctx, params) => {
try {
const body = await parseBody(req, clearOutcomeSchema);
const result = await clearInterestOutcome(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);