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

@@ -1,6 +1,9 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
export const INVOICE_KINDS = ['general', 'deposit'] as const;
export type InvoiceKind = (typeof INVOICE_KINDS)[number];
export const createInvoiceSchema = z
.object({
billingEntity: z.object({
@@ -15,6 +18,9 @@ export const createInvoiceSchema = z
.default('net30'),
currency: z.string().length(3).default('USD'),
notes: z.string().max(2000).optional(),
/** Optional link to a sales interest. Required when kind === 'deposit'. */
interestId: z.string().min(1).optional(),
kind: z.enum(INVOICE_KINDS).default('general'),
lineItems: z
.array(
z.object({
@@ -26,6 +32,10 @@ export const createInvoiceSchema = z
.optional(),
expenseIds: z.array(z.string()).optional(),
})
.refine((data) => data.kind !== 'deposit' || !!data.interestId, {
message: 'Deposit invoices must be linked to an interest',
path: ['interestId'],
})
.refine(
(data) =>
(data.lineItems && data.lineItems.length > 0) ||
@@ -41,6 +51,8 @@ export const updateInvoiceSchema = z.object({
paymentTerms: z.enum(['immediate', 'net10', 'net15', 'net30', 'net45', 'net60']).optional(),
currency: z.string().length(3).optional(),
notes: z.string().max(2000).optional(),
interestId: z.string().min(1).nullable().optional(),
kind: z.enum(INVOICE_KINDS).optional(),
lineItems: z
.array(
z.object({
@@ -68,7 +80,10 @@ export const listInvoicesSchema = baseListQuerySchema.extend({
billingEntityId: z.string().optional(),
});
export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>;
export type UpdateInvoiceInput = z.infer<typeof updateInvoiceSchema>;
// `z.input` keeps fields with `.default()` (paymentTerms, currency, kind)
// optional from the caller's perspective. The schema parser still fills in
// the defaults, so the service body can rely on them being present at runtime.
export type CreateInvoiceInput = z.input<typeof createInvoiceSchema>;
export type UpdateInvoiceInput = z.input<typeof updateInvoiceSchema>;
export type RecordPaymentInput = z.infer<typeof recordPaymentSchema>;
export type ListInvoicesInput = z.infer<typeof listInvoicesSchema>;