Recent Uploads vs Statements — list redesign + search/filters
Product design + decisions. Owner: Salil. Status: design (not built). 2026-06-25. Grounded in the current code:
features.md, the console views (app/components/console/views/*), and the/v1/statementslist endpoint. Feeds../go-to-market/action-items.md.
The problem (current state)
There is one list today (Statements) and it mixes three different things:
- Failed uploads (status
failed, no report) show up with a red badge, sitting among real statements. A failed parse is not a “statement” — it pollutes the underwriting library. - In-flight uploads (
queued/processing) and needs_review holds are there too. - Done statements (full and partial) are the actual analyzed inventory.
It has no search and no filters (just newest-first + paging), and no record of who uploaded
a file (jobs are org-level: Job.requester_id is the tenant, and the submit path throws away
the Bearer user). As volume grows, “find this borrower’s statement” and “what did my colleague
upload yesterday that failed” are both impossible.
The decision: two purpose-built views
Split the one list into an operational view and a library view. They answer different questions, so they get different columns, filters, and a different primary download.
| Recent Uploads (operational) | Statements (library) | |
|---|---|---|
| Question it answers | ”What did we upload and what happened to it?" | "Find an analyzed statement to underwrite / file.” |
| Unit | one row per upload event | one row per analyzed statement |
| Includes | every status: queued, processing, failed, needs_review, done (full/partial) | only done (full or partial, i.e. a report exists) |
| Excludes | nothing | failed, queued, processing (no report yet) |
| Key columns | file name, uploaded by (user), uploaded at, size, status, outcome (✓ full / ◐ partial N/M / ✗ failed + reason) | holder, bank, statement period, risk band, score, pages, analyzed date |
| Primary download | the original file (source) | the report (PDF) — wired into the row, not only the analyze view |
| Primary actions | download original, retry a failed upload, delete | open report, download report PDF, add to a case, download original (if retained), delete |
| Default sort | upload time DESC | analyzed date DESC |
Where overlap is fine: a done statement legitimately appears in both — as an upload event
in Recent Uploads (download the original, see who uploaded) and as a library entry in Statements
(open/▾ the report, search/filter). This mirrors the familiar “Activity feed” vs “Library” pattern.
needs_review lives in Recent Uploads as an outcome to resolve; once released it becomes a
Statement. failed lives only in Recent Uploads (there is no statement to show).
Search & filters (all lists, server-side)
Lists already paginate server-side, so filtering must be server-side too (don’t filter a single
page). Add query params to the list endpoints and a shared FilterBar client component (pairs
with the existing shared Pager).
| List | Free-text search | Filters |
|---|---|---|
| Statements | holder, bank, file name, job id | risk band, bank, analyzed date range, partial-only, in-a-case |
| Recent Uploads | file name, holder, bank, job id | status (failed/processing/done/needs_review), uploaded-by (user), upload date range, source-retained |
| Cases | borrower name, external ref | status, purpose, created date range |
| Queue | borrower, product | status, engine action, required level, assignee |
| Team | role, authority level, status |
Mechanics: GET /v1/statements?q=&status=&bank=&risk_band=&from=&to=&uploaded_by=&partial= etc.;
client debounces q (~300ms), resets to offset 0 on any filter change, keeps {items, total} so
the Pager count stays correct. Reuse one FilterBar (text input + a row of select/daterange
chips) across every view so it is one component, not five.
Data model change: “uploaded by” (the one migration)
Today there is no user attribution on a job. To show “uploaded by
- New column
Job.uploaded_by_user_id(str | None, FKusers.id) + an Alembic migration (nullable, backfilled NULL — old rows show ”—” / “API”). - Capture it at submit.
require_tenantalready decodes the Bearer user but discards it; add a companion dependencycurrent_user_optional(or have the route resolve it) and pass the user intocreate_statement_job(..., uploaded_by=user.id if user else None). API-key uploads have no user → store NULL, render as “API” (machine upload). - Expose it on the list. The list endpoint joins
usersand addsuploaded_by_emailto each row (the queue code’s_email_map(user_id → email)is the pattern). Adduploaded_byas a filter param. - The same attribution flows to the case-upload path (shared
create_statement_job).
Nothing else needs a schema change: source_available / source_state already exist for the
original-file download/retention state, and partial is already pages_processed < pages_total.
Downloads (the user-facing asks)
- Recent Uploads → original file. The source download already exists (
GET /v1/statements/{id}/source, gated by retention); surface its button here as the primary action. - Statements → report PDF.
GET /v1/statements/{id}/result.pdfexists but the button only lives in the analyze view today. Add a “Download report” action directly on the Statements row (reuseapi.resultPdfBlob). Keep “download original” as a secondary action when the source is still retained.
Failed uploads (direct answer to “how are they shown today”)
Today: a failed job renders as a red failed badge inside the Statements list, with the error only visible if you open it. After this change: failed uploads appear only in Recent Uploads, with the failure reason inline and a Retry action (re-run from the retained raw while it is within its TTL, else re-upload). They no longer pollute the Statements library.
Additional items this surfaced (see action-items.md)
- Retry a failed/partial upload from Recent Uploads (no retry exists today).
- Export Statements to CSV (parity with the approvals-queue export).
- needs_review resolve UI (flagged 🔲 in features.md — the API exists, the UI does not).
- Bulk select / bulk delete / bulk add-to-case (none today) — later.
- Per-row “Download report” on Statements (above).
Build sequencing (recommended)
- Server filters + search on
/v1/statements(q, status, bank, risk_band, date range, partial) + the shared clientFilterBaron the existing single list. Immediate value, no migration. - Split the view into Recent Uploads (all statuses) and Statements (done-only), each with its filter set; wire per-row report download on Statements.
uploaded_bymigration + capture + display + filter (the only schema change).- Retry action + CSV export + roll
FilterBarout to cases/queue/team.
Steps 1-2 are the bulk of the UX win and need no migration; step 3 adds the attribution.