PR: #375: feat(documents): document detail drawer — full record + status timeline [Story-1515] by @ankitmaheshwari-com
View PR →
Story 1515 · EPIC-1489 Documents & Intake · DOCS-DRAWER

The Documents Register drawer — the whole truth behind one row

The register row (Story-1514) tells a litigator a document's posture at a glance. Story-1515 answers the next question — "open it, tell me everything" — without leaving the register. Click a row and a right-anchored drawer slides in carrying the full record: both names (the AI clean name over the original filename it can never overwrite), the AI summary, the classified properties (document type, derived filing type/state, tags, proceeding, pages · size · status), who uploaded it, and the processing-status timeline read from document_status_events — the document's custody trail. A download button presigns the original.

🛰️ Satellite — why a drawer, not a page

The stakes: a litigator triaging a 400-document matter cannot afford a full page navigation to inspect one file and lose her place in the register. The naive fix — a route per document — detonates on contact with the workflow: it unmounts the register, drops the slice/sort/scroll she built, and turns "check this one thing" into a round trip. WHY a drawer: it is an overlay on the same surface — the register stays mounted underneath, the row that opened it stays selected, and closing the drawer returns her to exactly where she was. The detail is additive, never a context switch.

And WHY the timeline matters specifically: in legal work provenance is the product. "When did this become evidence, who (or which agent) moved it there, and did anything fail?" is not metadata trivia — it is the chain of custody. The timeline surfaces document_status_events as a human-readable trail so that truth is one click from any row.

✈️ Airplane — the shape of the change

One sustained analogy: the drawer is a case file jacket. The register is the cabinet; each row is a labelled tab. Pull a tab and the jacket opens to show the same facts in full — the cover (names + format), the index card (properties), the routing slip (custody timeline), and a copy slot (download). Nothing about the cabinet changes while a jacket is open.

LayerWhat it doesWHY here
lib/documents/detail/ (pure) DocumentDetail view-model + toDocumentDetail / toStatusTimeline adapters + tests. The jacket's contents, decided with no React and no fetch — so the mapping is unit-testable and the leaf is dumb.
components/documents/detail/ (leaves) DocumentStatusTimeline + DocumentDetailDrawer, Storybook-first. The jacket's look, verified in isolation (loading / error / empty / Mode-1 / failed) before any backend.
…/document-detail-drawer-container.tsx Wires the generated hooks to the leaf. The one place the live data seam lives — keeps the leaf Storybook-pure.
lib/documents/gateway.ts + api/internal/documents/[docId]/… BFF proxy + assertContract at the trust boundary. The browser only talks to /api/internal/*; firm scoping + HR-21 contract parsing happen server-side.

🔬 Microscopic — the seams that carry the truth

The view-model reuses the row, it does not re-derive it

DocumentDetail extends RegisterDocument. Every Mode-1 column the table already mapped (names, format, size, status, flags) is reused as-is; the drawer only layers on the two fields the table does not show — the AI summary and the uploader. WHY extend rather than re-fetch: one source of truth for what a document is, so the drawer and the row can never disagree.

Mode discipline — nullable, never hidden

PRD v2: Mode-2 / AI fields stay nullable until EPIC-009 fills them. A still-processing document shows "processing…" for its name/summary and "—" for the unclassified properties rather than dropping the rows. WHY render the empty truth: the first real run is a brand-new firm with an unprocessed corpus — the drawer must read honestly there, not only when fixtures carry happy data.

The timeline reads document_status_events via src/gen

// gateway.ts — trust boundary (HR-21 / ADR-061)
const upstream = await gatewayResponse<unknown>(`/documents/${docId}/status-events`);
return assertContract(documentStatusEventListResponseSchema, upstream.data, { … });

// container — generated TanStack hook → /api/internal proxy → functions
const events = useAppListDocumentStatusEvents(docId, undefined, { query: { enabled: open } });
<DocumentStatusTimeline entries={toStatusTimeline(events.data)} … />

Firm tenancy is enforced upstream: gatewayResponse attaches the caller's Clerk bearer and pramaan-functions scopes every row by firm membership — a document outside the firm 404s, never another firm's custody trail.

Live-data seam & verification scope. The register table is not yet mounted on a live route (that is a downstream EPIC-1489 ticket), so this Story is verified Storybook-first per repo doctrine: every leaf has stories covering the populated, Mode-1 processing, failed, loading, error, and empty states, plus an OpensOnRowClick composition story proving the row-click → drawer wiring. The live path — generated hooks → /api/internal/documents/[docId]/… proxy → gateway with assertContract → pramaan-functions — is built and compiles end-to-end; it goes hot when the register page mounts RegisterTable with the DocumentDetailDrawerContainer.

One-sentence recall

Click a row → a jacket opens over the same cabinet showing the full record and the custody timeline, with Mode-2 blanks rendered as honest "processing…/—".

Acceptance criteria

Files changed

17 file(s), +1015 / -0 lines

Path Role + -
messages/en.jsonConfig+24-0
messages/hi.jsonConfig+24-0
src/app/api/internal/documents/[docId]/download-url/route.tsApp route+4-0
src/app/api/internal/documents/[docId]/status-events/route.tsApp route+4-0
src/components/documents/detail/document-detail-drawer-container.tsxComponent+74-0
src/components/documents/detail/document-detail-drawer.stories.tsxComponent+115-0
src/components/documents/detail/document-detail-drawer.tsxComponent+190-0
src/components/documents/detail/document-status-timeline.stories.tsxComponent+89-0
src/components/documents/detail/document-status-timeline.tsxComponent+114-0
src/components/documents/detail/presentation.tsComponent+24-0
src/lib/documents/detail/adapter.test.tsLibrary+93-0
src/lib/documents/detail/adapter.tsLibrary+65-0
src/lib/documents/detail/index.tsLibrary+13-0
src/lib/documents/detail/types.tsLibrary+54-0
src/lib/documents/gateway.tsLibrary+65-0
src/lib/documents/route-helper.tsLibrary+55-0
src/lib/kubb-client.tsLibrary+8-0