Intake triage queue: bulk upload, auto-classify, and one-click project assignment
Four commits add a `/intake` queue where documents land before being assigned to projects. Each upload triggers an LLM classification in the background; the UI polls for results and lets users route each document to an existing project or spin up a new one. This is the "front door" that feeds the counterparty index.
32b1f25b is the load-bearing change. Migration 006_document_intake.sql adds seven intake_* columns to documents: role (buyer/seller/mutual), status (draft/execution/unknown), counterparty, parent_counterparty, lifecycle_hint, confidence, and analyzed_at. All nullable, so back-compat with existing rows is clean.
lib/intakeAnalysis.ts runs one LLM call per upload. The prompt returns a single JSON object with all seven fields in one shot - maxTokens 300, using first 12,000 characters of document text. The call is fire-and-forget; upload responses don't wait on it. A partial index on (user_id, intake_analyzed_at desc) WHERE project_id IS NULL keeps the intake queue query fast.
IntakeOverview.tsx (~318 LOC) is a polling list - 4-second interval while any document is mid-analysis. It shows role+status+lifecycle pills per document, fuzzy-matched project suggestions, and a "Create new project" button that pre-selects template by detected role.
230083fa refines the UX: the projects list swaps its CM/Chats columns for Type and Counterparty pills, and "Create new project" auto-names the project as "Counterparty - LIFECYCLE_HINT" (e.g., "Adobe Inc. - SOW"). A fall-through "Assign to..." dropdown appears when fuzzy matching finds no confident suggestion.
69aef399 promotes unassigned documents to first-class objects. POST /single-documents/bulk-assign accepts either {document_ids, project_id} or {document_ids, new_project: {...}}. The backend checks ownership and unassigned status for every ID before any mutation. Role filter chips with counts appear on the intake page. A sticky bulk-action bar lets users select multiple documents and create one project from them, using majority-vote on template and counterparty across the selection. The customer index (/projects/counterparties and the timeline endpoints) also folds standalone docs in.
b375d048 surfaces the standalone-doc count on each counterparty row in the index (+N standalone pill), adds a footer "view full timeline" link, and synthesises an "Unassigned" entry in CounterpartyTimelineView so /customers/Adobe Inc. renders even when no project for Adobe exists yet.
One thing to check before adopting: the 4-second polling cadence is fine at small scale but each document analysis is a billable LLM call. There's no UI signal for extraction failures - silent failures will appear as missing data.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?