Skip to Content
ProductUploads & statements (design)

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/statements list 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.”
Unitone row per upload eventone row per analyzed statement
Includesevery status: queued, processing, failed, needs_review, done (full/partial)only done (full or partial, i.e. a report exists)
Excludesnothingfailed, queued, processing (no report yet)
Key columnsfile 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 downloadthe original file (source)the report (PDF) — wired into the row, not only the analyze view
Primary actionsdownload original, retry a failed upload, deleteopen report, download report PDF, add to a case, download original (if retained), delete
Default sortupload time DESCanalyzed 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).

ListFree-text searchFilters
Statementsholder, bank, file name, job idrisk band, bank, analyzed date range, partial-only, in-a-case
Recent Uploadsfile name, holder, bank, job idstatus (failed/processing/done/needs_review), uploaded-by (user), upload date range, source-retained
Casesborrower name, external refstatus, purpose, created date range
Queueborrower, productstatus, engine action, required level, assignee
Teamemailrole, 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 ”:

  1. New column Job.uploaded_by_user_id (str | None, FK users.id) + an Alembic migration (nullable, backfilled NULL — old rows show ”—” / “API”).
  2. Capture it at submit. require_tenant already decodes the Bearer user but discards it; add a companion dependency current_user_optional (or have the route resolve it) and pass the user into create_statement_job(..., uploaded_by=user.id if user else None). API-key uploads have no user → store NULL, render as “API” (machine upload).
  3. Expose it on the list. The list endpoint joins users and adds uploaded_by_email to each row (the queue code’s _email_map(user_id → email) is the pattern). Add uploaded_by as a filter param.
  4. 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.pdf exists but the button only lives in the analyze view today. Add a “Download report” action directly on the Statements row (reuse api.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).
  1. Server filters + search on /v1/statements (q, status, bank, risk_band, date range, partial) + the shared client FilterBar on the existing single list. Immediate value, no migration.
  2. Split the view into Recent Uploads (all statuses) and Statements (done-only), each with its filter set; wire per-row report download on Statements.
  3. uploaded_by migration + capture + display + filter (the only schema change).
  4. Retry action + CSV export + roll FilterBar out to cases/queue/team.

Steps 1-2 are the bulk of the UX win and need no migration; step 3 adds the attribution.