Skip to content

Admin — V1 Upload (M15 Cutover Flow)

The operator-facing UI that wraps the M15 ETL + reconciliation pipeline. The screen Stefan reaches once at V2 cutover (and a handful of times before, in dress-rehearsal mode against the test DB). Owned by Mira. Cross-cuts: process — V1 upload spec (the operator contract this UI implements), v2-scope M15, data-model § Migration shape, ADR-0007 § Order lifecycle, ADR-0008 § Migration from V1, admin-catalogue-moderation (admin-shell precedent), design-tokens.

Source requirement

  • v2-scope M15 — "Migrate full XLSX history: 329 client orders + 416 self orders + Stefan's rackets list."
  • process — V1 upload spec — the canonical operator contract. The CLI form (python -m migration.etl --xlsx … --reconcile --dry-run) is the technical baseline; this spec wraps that pipeline behind a browser-driven admin UI so Stefan does not need a shell on the production VPS at cutover.
  • migration/etl.py + migration/report.py — Vera's existing M15 implementation (MR !40, MR !54). The UI calls into this same pipeline; it does not re-implement parsing or reconciliation.
  • ADR-0007, ADR-0008 — date-causal relaxation + receipt-number assignment; the UI must show the warning counts these ADRs predict.

Goal

Three commitments:

  1. The dry-run + report is the gate, not the upload itself. The admin UI's hero job is rendering the reconciliation report in a form Stefan can sign off on with one glance at the Summary status line. The actual write-path is mechanical once the report is OK.
  2. Approve and Cancel are the only two outcomes. No partial commits, no "approve some rows", no override. If the report is FAIL, Stefan fixes V1 and re-uploads; the UI does not let him bypass.
  3. One operator, one session, one cutover. Stefan runs this flow at most a few times: dress rehearsal against the test DB, then once at the production switch-over. The UI optimises for clarity over throughput — a long, scrollable report is fine; a single primary CTA per stage is the pattern.

Information architecture

Admin-only route family at /admin/v1-upload. The flow is a four-stage state machine; each stage corresponds to a route, and the URL is the source of truth so a refresh / accidental tab-close doesn't lose state.

Admin
├── Dashboard (existing)
└── V1 upload (new — admin-only)
    ├── Stage 1 — Upload                    GET  /admin/v1-upload
    │                                       POST /admin/v1-upload (multipart/form-data)
    │                                            → kicks off dry-run, redirects to ↓
    ├── Stage 2 — Dry-run report             GET  /admin/v1-upload/runs/:run_id
    │   ├── Summary (OK / FAIL)
    │   ├── Differences (when non-clean)
    │   ├── Counts table
    │   ├── Per-year revenue table
    │   ├── Mean turnaround
    │   ├── V2 entity totals
    │   ├── Per-client top-20
    │   ├── Data-quality issues
    │   ├── ETL plan (insert / skip / synthesize counts)
    │   ├── [Cancel]   [Approve and commit]
    │   └── (CTAs disabled when status = FAIL)
    ├── Stage 3 — Commit in flight           GET  /admin/v1-upload/runs/:run_id/commit
    │   ├── Progress indicator (stage-by-stage)
    │   └── Auto-redirects on completion → ↓
    └── Stage 4 — Final summary              GET  /admin/v1-upload/runs/:run_id/done
        ├── Final ETLStats table
        ├── Receipt-counter seed values
        ├── Provenance record link
        └── [Back to admin dashboard]

The list of past runs (one per upload) lives at /admin/v1-upload/runs — useful when Stefan does the dress rehearsal against test DB then comes back days later for prod cutover.

Stage 1 — Upload

/admin/v1-upload

sm 375 px

┌───────────────────────────────────────┐
│ ←  V1 upload                          │  Header
├───────────────────────────────────────┤
│                                       │
│  V1 → V2 cutover upload               │  H1
│                                       │
│  Upload your current V1 XLSX. We'll   │  text-body slate-700
│  parse it, run reconciliation against │
│  this database in dry-run mode, and   │
│  show you a report. Nothing is        │
│  written until you approve.           │
│                                       │
│  Database:  rbo_test                  │  text-small slate-600
│             (set by DATABASE_URL)     │  text-tiny slate-500
│                                       │
│  ─── XLSX file ───                    │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  📄 Choose file…                  │ │  Native <input type="file">
│ │  No file selected                 │ │  styled
│ └───────────────────────────────────┘ │
│  Accepted: .xlsx · max 25 MB          │  text-tiny slate-500
│                                       │
│ ┌───────────────────────────────────┐ │
│ │      Run dry-run                  │ │  Primary CTA, full width
│ └───────────────────────────────────┘ │
│                                       │
│  Past runs →                          │  text-small indigo-700
│                                       │
└───────────────────────────────────────┘

md 768 px+

Same layout, max-width 720 px, centered. The "Past runs" link moves to a side affordance in the page header.

Behaviour

  • Database name — server reads DATABASE_URL_DIRECT (or its alias) and surfaces the database name on the page so Stefan never confuses test vs prod. Background colour shifts: slate-50 for rbo_test, amber-50 for rbo_prod (production gets a visual heads-up).
  • File picker — native <input type="file" accept=".xlsx">. The styled label updates to show the picked filename. No drag-and-drop in V2 (the picker is fast enough; drag-and-drop is a polish round).
  • Client-side guard — if file size exceeds 25 MB, surface inline error before submit ("File too large — V1 XLSX is typically under 5 MB; check you picked the right file."). Server-side limit is canonical; this is a UX shortcut.
  • Submit — multipart POST. The server kicks off the dry-run synchronously (V1 XLSX is small; 329 + 416 rows parses in < 1 s on the keystone VPS) and 303-redirects to /admin/v1-upload/runs/:run_id once the report is rendered. No background job; no polling. Why synchronous: the parse + dry-run is fast enough that a spinner-on-submit is the right UX, and avoiding a queue / worker keeps the cutover tooling dependency-free.
  • Loading state — submit button switches to "Running dry-run…" with a 16 px spinner; disabled while in flight. Page does not navigate; HTMX is not used here (regular POST + redirect, since we want the URL to advance to the report on success).
  • Past runs link — opens /admin/v1-upload/runs (a simple list of run records: timestamp, filename, status badge, link to its Stage-2 report). Useful for dress-rehearsal continuity.

Component breakdown

Component Notes
Header Back arrow → admin dashboard. Title "V1 upload" — text-h1.
Database chip text-tiny font-mono in a chip; background slate-50 (test) or amber-50 (prod). Reads from server.
File input Native <input> with custom label styling. Hit target ≥ 48 px.
Hint line "Accepted: .xlsx · max 25 MB" — text-tiny slate-500.
Primary CTA "Run dry-run" — bg-indigo-700 text-white, full width on sm, fixed-height 48 px.
Past runs link text-small text-indigo-700 underline.

Error states (Stage 1 → submit)

Error Inline message Recovery
No file picked "Pick an XLSX file before running the dry-run." Stays on form; focus jumps to file input.
File > 25 MB "File too large (max 25 MB). V1 XLSX is typically under 5 MB — check you picked the right file." Stays on form.
Wrong file type "Only .xlsx files are accepted." Stays on form.
Parser fails to open the workbook (corrupt / not a real XLSX) "Couldn't open the workbook. The file may be corrupt or not a valid XLSX. Try re-exporting from V1." Stays on form; full server-side ValueError summary shown in a collapsible "Technical details" block underneath.
Header drift (parser _assert_header raises) "V1 column layout has changed — the parser couldn't find expected headers. See details below." Server returns the parser's exact error message including the Sheet:Row reference and what was expected vs found. Stage 1 shows the error inline; Stefan fixes V1 (renames the column back) and re-uploads. No partial parse.
Server / network error (5xx) Toast "Something went wrong on our side. Try again." Form values are not preserved (file inputs don't survive POST failures); Stefan re-picks the file.

Stage 2 — Dry-run report

/admin/v1-upload/runs/:run_id

The hero screen of this flow. Renders the same content migration/report.py::render_markdown produces, but as a paginated HTML page with the Summary status line above the fold and the long-tail tables below.

sm 375 px

┌───────────────────────────────────────┐
│ ←  Dry-run report                     │  Header (back → Stage 1)
├───────────────────────────────────────┤
│                                       │
│  ✓ OK                                 │  Status badge — green-700 bg
│                                       │
│  329 client orders · 416 self orders  │  Summary line — text-body
│  Tolerance: ±0.01 CHF                 │  text-small slate-600
│  Run on: 2026-05-06 14:22 (8s ago)    │  text-tiny slate-500
│  File: stringing_orders.xlsx (4.1 MB) │  text-tiny slate-500
│  Database: rbo_test                   │  text-tiny slate-500
│                                       │
│  ─── ETL plan ───                     │
│                                       │
│  Will insert:                         │  text-small
│   • 329 orders (client)               │
│   • 416 orders (self)                 │
│   • 47 persons / client_profiles      │
│   • 31 rackets (Stefan's owned)       │
│   • 6 rackets (synthesised — orphans) │  + warn icon
│   • 43 strings (catalogue-shared)     │
│  Will skip (already migrated):        │
│   • 0 (this is a fresh run)           │
│  Receipt counters seeded:             │
│   • 2019: last_n=42 → 2026: last_n=89 │
│                                       │
│  ─── Counts ───                       │
│                                       │
│  Category    V1   V2   Δ              │  Table
│  Client      329  329  0  ✓           │
│  Self        416  416  0  ✓           │
│                                       │
│  ─── Per-year revenue (CHF) ───       │
│                                       │
│  Year   V1       V2       Δ           │
│  2019   1234.50  1234.50  0.00 ✓      │
│  2020   2234.00  2234.00  0.00 ✓      │
│  ⋯                                    │
│  2026   1112.00  1112.00  0.00 ✓      │
│                                       │
│  ─── Mean turnaround ───              │
│                                       │
│  Sample: 318 client orders            │
│  Mean: 4.2 days · Median: 3 days      │
│  Min: -1 day (date-causal violation)  │
│  Max: 21 days                         │
│                                       │
│  ─── Per-client top 20 ───            │
│                                       │
│  Lukas Müller          47             │
│  Anna Bauer            38             │
│  ⋯                                    │
│  (self)               416             │
│                                       │
│  ─── Data-quality issues (18) ───     │  Collapsible
│ ▼ See all warnings                    │
│                                       │
│  ┌────────────────────────────────┐   │
│  │  Cancel                        │   │
│  └────────────────────────────────┘   │
│  ┌────────────────────────────────┐   │
│  │  Approve and commit            │   │  Primary CTA — disabled if FAIL
│  └────────────────────────────────┘   │
│                                       │
│   This will write 329 + 416 orders    │  text-tiny slate-500
│   to rbo_test. Idempotent — re-runs   │
│   skip already-imported rows.         │
│                                       │
└───────────────────────────────────────┘

md 768 px+

Same content, max-width 960 px (the tables earn the wider real estate). Tables become two-column row-by-row blocks on sm; on md+ they render as proper <table> with column alignment. Sticky bottom bar with Cancel + Approve stays visible as Stefan scrolls long-tail tables.

FAIL variant

┌───────────────────────────────────────┐
│ ←  Dry-run report                     │
├───────────────────────────────────────┤
│                                       │
│  ✕ FAIL                               │  Status badge — red-700 bg
│                                       │
│  329 client orders · 416 self orders  │
│  Tolerance: ±0.01 CHF                 │
│                                       │
│  ─── Differences ───                  │  H2 — red-700 prominent
│                                       │
│  • 2024 revenue: V1 1234.50 vs V2     │  text-body
│    1230.00 (Δ -4.50 CHF)              │
│  • Order count mismatch:              │
│    V1 329 vs V2 328 (Δ -1)            │
│                                       │
│  Investigate before committing.       │  text-small slate-700
│  Common causes:                       │
│  • Date-causal violation orphaning    │
│    a row from `Strung` aggregation    │
│  • Manually-edited price in V1 that   │
│    breaks the Strings = sum-of-sides  │
│    invariant                          │
│  Fix in V1 and re-upload.             │
│                                       │
│  ─── Counts ───                       │  Same tables follow, for
│  ⋯                                    │  audit-trail visibility
│                                       │
│  ┌────────────────────────────────┐   │
│  │  Cancel and re-upload          │   │  Only CTA available
│  └────────────────────────────────┘   │
│                                       │
│   Approve disabled while FAIL.        │  text-tiny slate-500
│                                       │
└───────────────────────────────────────┘

Behaviour

  • Status badge — top of page; the at-a-glance signal:
  • ✓ OKbg-green-50 text-green-800 border-green-200. Approve CTA enabled.
  • ✕ FAILbg-red-50 text-red-700 border-red-200. Approve CTA hidden; only "Cancel and re-upload" shown.
  • Summary line — V1 row counts + tolerance. Mirrors the render_markdown Summary section.
  • Differences section — only renders when status = FAIL. Bullet list of the specific count + per-year revenue mismatches that caused the failure (verbatim from the report data). Sub-tolerance revenue diffs are NOT echoed here — see process spec § Reconciliation report.
  • ETL plan — what the commit will do, in concrete numbers. Renders the migration.etl::ETLStats projected from the dry-run:
  • Insert counts per V2 entity (orders, persons, client_profiles, rackets, strings).
  • Skip counts (skipped_idempotent — non-zero on re-runs).
  • Synthesised-racket count (orphan racket-IDs in self-orders; clickable to expand the list of synthesized IDs with their Sheet:Row references — provenance is the contract).
  • Receipt-counter seed values per year (the _seed_receipt_counters projection).
  • Counts / Per-year revenue / Mean turnaround / V2 entity totals / Per-client top-20 — render the migration/report.py data verbatim. Each table cell that crosses the tolerance threshold gets a red Δ; cells within tolerance get a green ✓.
  • Data-quality issues — collapsed by default (Stefan has 18 known V1 warnings per process spec § Upload checklist; listing them all on first scroll is noise). Expanding shows every warning with its Sheet:Row reference and message. Each row is text-small with the sheet+row in font-mono.
  • Approve and commit CTA — primary; only enabled when status = OK. Tapping does not commit immediately — opens the Stage 3 typed-confirm modal (next section). Why typed-confirm: the prod commit is the one irrevocable step in the flow; the dress-rehearsal-on-test pattern means Stefan can practise this multiple times, but we still want a deliberate moment before writing to prod.
  • Cancel CTA — discards the run. Returns to Stage 1 with a toast "Dry-run cancelled. Upload again to retry."
  • Sticky bottom action bar (md+)Cancel | Approve and commit stays visible as Stefan scrolls the long-tail tables. On sm the buttons are at the bottom of the page (no sticky bar — the page is short enough at phone widths).

Component breakdown

Component Notes
Status badge text-h2 size, full-width chip. OK green-700 / FAIL red-700. Icon lucide:check-circle / lucide:x-circle.
Summary line text-body slate-700. Counts + tolerance + run-time + filename.
Section H2s text-h2 slate-900; horizontal bg-slate-200 rule above.
ETL plan list <ul> with bullet markers; text-small. Insert / skip / synthesise counts in font-mono tabular-nums.
Reconciliation tables <table> with font-mono tabular-nums for numbers, alternating-row bg-slate-50.
Δ cells Coloured: green ✓ when within tolerance, red value when outside.
Data-quality collapsible <details> <summary>; summary text "See all warnings (N)".
Cancel CTA bg-white border border-slate-300 text-slate-700.
Approve CTA bg-indigo-700 text-white. Disabled when FAIL: disabled:bg-slate-300 disabled:text-slate-500 cursor-not-allowed.
Disabled-CTA hint text-tiny slate-500 below the button: "Approve disabled while FAIL."

Stage 3 — Approve and commit (typed-confirm modal + progress)

Approve confirmation modal

Tapping "Approve and commit" opens a modal. The modal's friction is proportional to the database environment — rbo_test gets a one-tap confirmation; rbo_prod gets a typed-confirm.

   ╔═══════════════════════════════════╗
   ║                                   ║
   ║  Commit V1 → V2 upload?           ║  H2
   ║                                   ║
   ║  Will write to: rbo_prod          ║  text-body — bold for prod
   ║                                   ║
   ║  This will insert:                ║  text-small
   ║   • 329 client orders             ║
   ║   • 416 self orders               ║
   ║   • 47 persons + client profiles  ║
   ║   • 37 rackets (incl. 6 synth)    ║
   ║   • 43 strings                    ║
   ║                                   ║
   ║  The ETL is idempotent — if you   ║  text-tiny slate-500
   ║  re-run later, already-imported   ║
   ║  rows are skipped.                ║
   ║                                   ║
   ║  Type the database name to        ║  Required for rbo_prod only
   ║  confirm.                         ║
   ║ ┌─────────────────────────────┐   ║
   ║ │ rbo_prod                    │   ║  Text input — must match
   ║ └─────────────────────────────┘   ║
   ║                                   ║
   ║  ┌─────────────────────────┐      ║
   ║  │  Cancel                 │      ║
   ║  └─────────────────────────┘      ║
   ║  ┌─────────────────────────┐      ║
   ║  │  Commit                 │      ║  Disabled until name typed
   ║  └─────────────────────────┘      ║
   ║                                   ║
   ╚═══════════════════════════════════╝

Behaviour (modal)

  • rbo_test flavour — no typed-confirm; just two buttons (Cancel + Commit). Stefan does dress rehearsals; we don't make him type the name 5 times.
  • rbo_prod flavour — typed-confirm with the literal database name. Commit button disabled until the typed value matches exactly. Why the asymmetry: prod commit is the once-in-V2-cutover step; the friction is proportional. Pattern matches admin-person-merge typed-confirm and admin-dsar-queue erasure typed-confirm.
  • Submit (Commit) — POSTs to /admin/v1-upload/runs/:run_id/commit. Server:
  • Re-reads the stored XLSX from the run record.
  • Runs migration.etl.run() without --dry-run (real commit).
  • Updates the run record's status to committed with the final ETLStats payload.
  • Redirects to /admin/v1-upload/runs/:run_id/done.
  • In-flight UX — Commit button switches to "Committing…" with a 16 px spinner; modal stays open; backdrop opaque so the page is non-interactive. Server commit takes ~2-5 s on a 5 MB XLSX (no async needed).

Failure during commit

The dry-run was clean, so the only realistic commit-time failures are: - DB constraint violation (race: another tenant or operation modified data between dry-run and commit; vanishingly rare for V2 cutover but defensively handled). - Connection drop / network error. - Server crash mid-transaction (the ETL's idempotent design means the V1MigrationLog rows already written stay; the rest get rolled back at the transaction boundary).

Failure UX
DB constraint violation Modal closes; Stage 2 page reloads with a red banner: "Commit failed: {error message}. Some rows may have been written — re-run will skip them via the V1MigrationLog. Investigate before retrying." Approve CTA stays enabled (idempotency makes retry safe).
Network drop mid-commit Toast: "Commit may have completed. Reload to check." A reload either lands on Stage 4 (commit succeeded server-side) or stays on Stage 2 with an updated run record showing partial state.
Server crash mid-transaction Same as DB constraint case — Stage 2 reloads; the V1MigrationLog tells the truth on retry.
Disk full / OOM Toast: "Server out of resources. Contact the operator." Run record's status is failed; manual retry from Stage 1 (re-upload) or from /admin/v1-upload/runs archive.

Stage 4 — Final summary

/admin/v1-upload/runs/:run_id/done

The audit-trail page Stefan signs off on. Mirrors Stage 2 in shape but with the final ETLStats (not the dry-run projection) and a clear "Done" stance.

sm 375 px

┌───────────────────────────────────────┐
│ ←  V1 upload — done                   │  Header
├───────────────────────────────────────┤
│                                       │
│  ✓ Committed                          │  Status — green-700 bg
│                                       │
│  329 client orders · 416 self orders  │
│  Database: rbo_prod                   │
│  Committed at: 2026-05-06 14:24       │
│  Duration: 3.2 s                      │
│                                       │
│  ─── Final ETL stats ───              │
│                                       │
│  Inserted:                            │
│   • 329 orders (client)               │
│   • 416 orders (self)                 │
│   • 47 persons / client_profiles      │
│   • 31 rackets (Stefan's owned)       │
│   • 6 rackets (synthesised)           │
│   • 43 strings                        │
│  Skipped (idempotent):  0             │
│  Warnings emitted:     18             │
│                                       │
│  ─── Receipt counters ───             │
│                                       │
│  Year   last_n                        │
│  2019   42                            │
│  2020   58                            │
│  ⋯                                    │
│  2026   89                            │
│                                       │
│  ─── Provenance ───                   │
│                                       │
│  Every migrated row is logged in      │  text-small slate-700
│  v1_migration_log. Run id #41 ↗       │
│  links the per-row trail.             │
│                                       │
│  ┌────────────────────────────────┐   │
│  │  Back to admin dashboard       │   │
│  └────────────────────────────────┘   │
│                                       │
│   You can keep stringing in V1 and    │  text-tiny slate-500
│   re-upload later — the ETL is        │
│   idempotent.                         │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • No CTA other than "Back to admin dashboard" — Stage 4 is the read-only audit page. The dashboard is where Stefan goes next.
  • Provenance link — clickable; opens /admin/v1-upload/runs/:run_id/provenance (a paged read-only view of the V1MigrationLog rows for this run). Useful for the "wait, where did Order #218 come from?" investigation moment. Out of scope for V2: the provenance view is a basic table; richer JOINs are a polish round.
  • Re-run hint — the closing tiny-text reminds Stefan the ETL is idempotent. If he stringed any rackets between this commit and actual V1 retirement, he uploads his then-latest XLSX and re-runs; only new rows write.

Past runs index

/admin/v1-upload/runs

A simple list of historical runs. Each row links back to its Stage-2 (or Stage-4) page.

┌───────────────────────────────────────┐
│ ←  V1 upload — past runs              │
├───────────────────────────────────────┤
│                                       │
│  ┌───────────────────────────────────┐│
│  │ #41  ✓ Committed                  ││
│  │ rbo_prod · 2026-05-06 14:24       ││
│  │ stringing_orders.xlsx (4.1 MB)    ││
│  └───────────────────────────────────┘│
│  ┌───────────────────────────────────┐│
│  │ #40  ✓ Committed                  ││
│  │ rbo_test · 2026-05-04 09:11       ││
│  │ stringing_orders.xlsx (4.1 MB)    ││
│  └───────────────────────────────────┘│
│  ┌───────────────────────────────────┐│
│  │ #39  ✕ FAIL (dry-run)             ││
│  │ rbo_test · 2026-05-03 18:42       ││
│  │ stringing_orders.xlsx (4.0 MB)    ││
│  └───────────────────────────────────┘│
│                                       │
└───────────────────────────────────────┘
  • Sorted newest first.
  • Status badges: Committed (green), FAIL (dry-run) (red), Cancelled (slate), Pending (amber — should be transient).
  • Each row links to its detail page (Stage 2 if not committed; Stage 4 if committed).

Interaction states

State What renders
Stage 1 — empty form Upload button disabled until file picked.
Stage 1 — file picked, not submitted Upload button enabled; filename shown.
Stage 1 — submit pending Button reads "Running dry-run…" with spinner; form disabled.
Stage 1 — parser error Form re-renders with inline red-banner error block; technical details collapsible.
Stage 2 — OK Status ✓ OK; Approve enabled; full report visible.
Stage 2 — FAIL Status ✕ FAIL; Differences section prominent; Approve hidden; Cancel-and-re-upload only.
Stage 2 — modal open (test DB) Two-button confirm modal.
Stage 2 — modal open (prod DB) Typed-confirm modal; Commit disabled until name typed.
Stage 2 — commit pending Modal disabled; "Committing…" spinner.
Stage 2 — commit failed Modal closes; banner-error on Stage 2 page. Approve still enabled (idempotent retry).
Stage 3 (intermediate) Strictly speaking, the synchronous commit doesn't render Stage 3 as its own page — the modal-with-spinner IS Stage 3. If the commit takes > 10 s (worst case on a slow VPS), the spinner is the only feedback.
Stage 4 — done Read-only summary; Back-to-dashboard CTA.
Past runs — empty "No previous runs. Upload an XLSX to start." Empty-state shape per admin-catalogue-moderation § Empty state.

Validation rules (UI surface; canonical server-side)

Rule Inline message
File required (Stage 1) "Pick an XLSX file before running the dry-run."
File too large (> 25 MB) "File too large (max 25 MB). V1 XLSX is typically under 5 MB — check you picked the right file."
Wrong extension "Only .xlsx files are accepted."
Workbook unparseable "Couldn't open the workbook. The file may be corrupt or not a valid XLSX. Try re-exporting from V1."
Header drift (parser raises) "V1 column layout has changed — the parser couldn't find expected headers. Check Sheet:Row {sheet}:{row} for column '{expected}'; got '{found}'."
Typed-confirm name doesn't match (prod) "Type the database name exactly to confirm." (Inline; submit disabled.)
Run already committed (race / re-tap) "This run was already committed at {timestamp}. View the result." (Toast; redirects to Stage 4.)
Run cancelled "This run was cancelled. Upload again to retry." (Toast; redirects to Stage 1.)

Accessibility

  • File input — native <input type="file"> with explicit <label> (the styled label is a <label> wrapping the input). Hit target ≥ 48 px.
  • Status badge — icon + text + colour: ✓ OK and ✕ FAIL are redundantly signalled (icon shape + word + colour), so colour-blind users see the difference.
  • Reconciliation tables<table> with <thead> headers; scope="col"; <caption> describing the table ("Counts: V1 vs V2 row counts per category"). Numbers in tabular-nums.
  • Δ cells — colour + a textual ✓ / value; never colour-only.
  • Approve CTA disabled statearia-disabled="true" and disabled attribute; the hint text below it (text-tiny slate-500) is aria-describedby-linked so screen readers announce the gating reason ("Approve disabled while FAIL").
  • Modal (typed-confirm)role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to H2 on open; focus trap; ESC dismisses. Typed-confirm input has aria-describedby linking to the hint "Type the database name to confirm."
  • Spinner / in-flight states — buttons gain aria-busy="true" while the operation runs.
  • Long-form report — landmark roles: <main> wraps the report; each H2 anchors a navigable section. <nav aria-label="Report sections"> at the top is not added in V2 (the report is short enough that scroll-to-section affordances are over-engineered for the once-per-cutover use case); flagged for polish if Stefan wants it.
  • Hit targets — every interactive element ≥ 44 × 44 px. CTAs are 48 px tall.
  • Contrast — Status badges meet WCAG AA: green-800 on green-50 = 7.4:1, red-700 on red-50 = 6.8:1.

HTMX / progressive-enhancement seams

The flow's fundamental contract is regular form POSTs + redirects. HTMX is minimal because the synchronous parse + dry-run + commit pattern does not benefit from partial page updates.

  • Stage 1 submit — regular <form method="post" enctype="multipart/form-data">. No HTMX. Server returns 303 redirect to Stage 2 on success.
  • Stage 2 → Approve modalhx-get="/admin/v1-upload/runs/:id/approve-modal" hx-target="#modal-region" opens the modal. Without JS: the Approve button is a regular <a href> to /admin/v1-upload/runs/:id/approve (a full-page version of the modal that submits to the same endpoint). Functionally identical; one extra full-page render without JS.
  • Modal submit (Commit) — regular <form method="post" action="/admin/v1-upload/runs/:id/commit">. No HTMX; the server commits and 303-redirects to Stage 4.
  • Cancel button<form method="post" action="/admin/v1-upload/runs/:id/cancel"> returning a redirect to Stage 1.
  • Data-quality collapsible — native <details> <summary>; no HTMX or JS needed.

The only JS-pleasant seam is the typed-confirm modal: with JS, the Commit button enables in real-time as the user types; without JS, the Commit button is always enabled and the server validates the typed name on submit, returning the modal page with a red-700 inline error if it doesn't match. Either way is functional.

i18n affordance

EN-only for V2. Per process spec, this UI is operator-facing for Stefan. The DE pass is deferred to a later round (and may stay EN-only forever — Stefan is bilingual and the cutover happens once). Mira drafts EN; if Iris ever asks, the strings below are catalogued for translation:

String Catalogue key
"V1 upload" (page title) admin.v1upload.title
"V1 → V2 cutover upload" (H1) admin.v1upload.h1
Body intro paragraph admin.v1upload.intro
"Database: {name}" admin.v1upload.db_label
"Choose file…" admin.v1upload.file_picker.label
"No file selected" admin.v1upload.file_picker.empty
"Accepted: .xlsx · max 25 MB" admin.v1upload.file_picker.hint
"Run dry-run" admin.v1upload.cta.run
"Past runs →" admin.v1upload.past_runs.link
"Dry-run report" (Stage 2 H1) admin.v1upload.report.h1
Status badges "OK" / "FAIL" / "Committed" / "Cancelled" / "Pending" admin.v1upload.status.{ok,fail,committed,cancelled,pending}
Section H2s "ETL plan" / "Counts" / "Per-year revenue" / "Mean turnaround" / "Per-client top 20" / "Data-quality issues" / "Final ETL stats" / "Receipt counters" / "Provenance" admin.v1upload.section.{etl_plan,counts,revenue,turnaround,top_clients,issues,final_stats,counters,provenance}
"Differences" admin.v1upload.section.differences
"Investigate before committing." + common-causes block admin.v1upload.differences.{prompt,causes}
Table column headers (Year / V1 / V2 / Δ / Category) admin.v1upload.table.{year,v1,v2,delta,category}
"See all warnings (N)" admin.v1upload.warnings.toggle
"Cancel" / "Approve and commit" admin.v1upload.cta.{cancel,approve}
"Approve disabled while FAIL." admin.v1upload.cta.approve_disabled
Approve modal title "Commit V1 → V2 upload?" admin.v1upload.modal.approve.title
"Will write to: {db}" admin.v1upload.modal.approve.target
"The ETL is idempotent…" hint admin.v1upload.modal.approve.idempotent_hint
"Type the database name to confirm." admin.v1upload.modal.approve.typed_confirm.prompt
"Commit" / "Committing…" admin.v1upload.modal.approve.cta.{commit,committing}
"Cancel and re-upload" admin.v1upload.cta.cancel_and_retry
"Back to admin dashboard" admin.v1upload.cta.back_to_dashboard
Stage 4 "V1 upload — done" admin.v1upload.done.h1
"Committed at: {timestamp}" / "Duration: {n}s" admin.v1upload.done.{committed_at,duration}
"You can keep stringing in V1 and re-upload later — the ETL is idempotent." admin.v1upload.done.reupload_hint
Validation messages admin.v1upload.validation.{file_required,too_large,wrong_ext,unparseable,header_drift,typed_confirm_mismatch,already_committed,cancelled_run}
Toast — commit failure / network drop messages admin.v1upload.toast.{commit_failed,network_drop,server_error}

Mobile-friendly note

Stefan does V2 cutover from a laptop, by his own description — but the dress-rehearsal practice runs may happen from a tablet (testing the test DB on a Saturday afternoon). The spec assumes:

  • md 768 px (tablet portrait) — fully supported. Tables become proper <table> with column alignment; sticky bottom action bar; max-width 960 px so the report tables don't sprawl edge-to-edge.
  • sm 375 px (phone) — supported but not optimised. The reconciliation tables degrade to row-by-row blocks (each cell becomes a "Label: Value" pair), which is readable but not as scannable as the tabular form. Why we don't optimise harder: the cutover-from-phone use case is not real; designing tables that look great at 375 px would distort the tablet/desktop layouts where this UI actually gets used.

If Stefan ever asks for a phone-first variant, the polish-round move is a stacked-card view of each reconciliation section with horizontal swipe to compare V1 / V2 / Δ — but that's not in V2 scope.

Cross-references

Open questions for Stefan (with proposed defaults)

  1. Stored XLSX retention. When Stage 1 uploads a file, the server writes it to disk for the dry-run + later commit. Proposed default: keep the XLSX on the run record indefinitely, alongside the JSON snapshot of the report. The size is small (4–5 MB per run; even 100 historical runs = 500 MB, trivial). Provides an audit trail for the cutover. Alternative: delete after commit. Mira leans keep.
  2. Authentication on the route family. Proposed default: gated on Stringer.role = 'admin', same pattern as the catalogue and DSAR queues. V2 is one admin (Stefan); the routes 403 for everyone else.
  3. Background-job vs synchronous commit. Proposed default: synchronous commit (the V1 dataset is small enough that even prod commit is < 5 s). Alternative: enqueue the commit, redirect to a polling Stage 3 page. Adds infra cost (a worker process) for negligible UX win at V2's scale. Defer to a future round if Stefan ever needs to migrate a much larger V1 dataset (he won't — V1 stops at cutover).
  4. Typed-confirm asymmetry test/prod. Proposed default: yes — test gets one-tap, prod gets typed-confirm. Friction proportional to consequence. Alternative: typed-confirm always (Stefan would tire of it during dress rehearsals); typed-confirm never (prod becomes too easy to misclick from a Stage-2 review session). Mira leans the asymmetric default.
  5. Failure during commit — auto-retry? Proposed default: no auto-retry; show banner + let Stefan re-tap Approve. The ETL's idempotency makes manual retry cheap. Alternative: auto-retry once with a backoff (could mask transient failures Stefan should see). Mira leans manual.
  6. Past-runs retention policy. Proposed default: keep all run records forever (size is trivial; audit trail is the contract). Alternative: 90-day retention. No real driver for deletion in V2; revisit if anything.
  7. Per-section drill-down on the dry-run report. Today, each section is a static block. Proposed default: keep static for V2 — Stefan does this once. Alternative: clickable rows that expand to show per-row provenance (e.g. clicking "47 persons" shows the 47 rows with their V1 origin). Polish round; not blocking V2 cutover.
  8. What happens to the run record when Stefan closes the tab mid-flow. Proposed default: the run record persists in pending status indefinitely; he can resume from /admin/v1-upload/runs and click the row to land back on Stage 2. Alternative: auto-cancel after 24 h. Adds a cron we don't need; the past-runs list shows pending rows clearly enough that Stefan won't be confused.
  9. Rendering the V1 file's parsed contents inline (preview). Proposed default: no — only the report. The reconciliation report tells Stefan what the import will do; showing him the raw rows is busywork. Alternative: a "preview first 20 rows" panel for sanity. Polish round if he asks.
  10. Concurrency guard — two admin tabs uploading at once. Proposed default: defensive 409 on Stage-1 POST if another pending run exists for the same admin in the last 5 minutes. Toast: "You have a dry-run already in progress at {url}. Cancel it before starting a new one." Alternative: allow concurrent runs (the run records are isolated). Mira leans defensive — confused-admin-with-stale-tabs is a real failure mode at cutover, single-admin-doing-it-once is the V2 reality.
  11. Receipt-counter seeding visibility. Proposed default: surface the per-year last_n values in both the ETL plan (Stage 2 projection) and Final stats (Stage 4 actuals) so Stefan can sanity-check that the next-issued receipt-number-per-year matches his memory of "I issued 89 receipts in 2026 so far". Alternative: hide as an implementation detail. Mira leans surface — receipt numbers are visible to clients, so getting them right is the customer-facing reason.