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

@@ -36,6 +36,28 @@ export const changeStageSchema = z.object({
reason: z.string().optional(),
});
// ─── Outcome (Won / Lost) ─────────────────────────────────────────────────────
export const INTEREST_OUTCOMES = [
'won',
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'cancelled',
] as const;
export type InterestOutcome = (typeof INTEREST_OUTCOMES)[number];
export const setOutcomeSchema = z.object({
outcome: z.enum(INTEREST_OUTCOMES),
reason: z.string().max(2000).optional(),
});
export const clearOutcomeSchema = z.object({
// Stage to revert to when reopening (defaults to in_communication).
reopenStage: z.enum(PIPELINE_STAGES).optional(),
});
// ─── List ─────────────────────────────────────────────────────────────────────
export const listInterestsSchema = baseListQuerySchema.extend({
@@ -168,3 +190,5 @@ export type ListInterestsInput = z.infer<typeof listInterestsSchema>;
export type WaitingListAddInput = z.infer<typeof waitingListAddSchema>;
export type PublicInterestInput = z.infer<typeof publicInterestSchema>;
export type ReorderWaitingListInput = z.infer<typeof reorderWaitingListSchema>;
export type SetOutcomeInput = z.infer<typeof setOutcomeSchema>;
export type ClearOutcomeInput = z.infer<typeof clearOutcomeSchema>;