4239 lines
138 KiB
Markdown
4239 lines
138 KiB
Markdown
# Cyclone Production-Readiness (local-only) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||
|
||
**Goal:** Add a backend in-memory batch store + 6 GET endpoints (with filter/sort/pagination and NDJSON streaming) to the Cyclone EDI suite, wire the existing 4 React pages + Upload page to live data via `@tanstack/react-query` v5, ship 4 fresh reference notes + a root README rewrite, and lock the local-only deploy posture (`127.0.0.1:8000`, no auth, no internet exposure). All on a single branch, with frequent commits, fully testable at every checkpoint.
|
||
|
||
**Architecture:** Threaded `InMemoryStore` (process-local, list of `BatchRecord` keyed by uuid4) → 6 read-only FastAPI routes + an `application/x-ndjson` content-negotiated streaming variant on each list endpoint. Frontend: `QueryClientProvider` at the root, 6 hooks under `src/hooks/`, 4 page refactors, 5 new UI primitives (`Skeleton`, `EmptyState`, `ErrorState`, `FilterChips`, `Pagination`), a Layout-level refetch indicator, and a newly-streamed row flash on the table. Docs: 4 short notes under `docs/reference/`, root README rewritten.
|
||
|
||
**Tech Stack:** Python 3.11+, FastAPI, Pydantic v2, `threading.RLock`; React 18 + Vite + TS + Tailwind, `@tanstack/react-query` v5, `lucide-react`, `sonner`, `zustand`, `clsx` + `tailwind-merge` (existing). No new Python deps; one new JS dep (`@tanstack/react-query`).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-06-19-cyclone-production-readiness-design.md` (approved)
|
||
|
||
**Worktree:** Use `superpowers:using-git-worktrees` to create `.worktrees/prod-readiness` from `main` before Task 1.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
### New backend files
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `backend/src/cyclone/store.py` | `BatchRecord`, `InMemoryStore`, mappers (`to_ui_claim`, `to_ui_remittance`, `to_ui_provider`, `to_activity_event`) |
|
||
| `backend/tests/test_store.py` | ~8 tests: add/list/get, threading, mappers, filters, distinct providers, activity |
|
||
| `backend/tests/test_api_gets.py` | ~12 tests: happy path + filter for each of 6 GET endpoints + 404 for `batches/{id}` |
|
||
| `backend/tests/test_api_streaming.py` | 1 test: `Accept: application/x-ndjson` on `/api/claims` returns parseable lines |
|
||
| `backend/tests/test_api_parse_persists.py` | 2 tests: parse-837 persists + appears via `/api/claims`; failed parse does NOT create a batch |
|
||
|
||
### Modified backend files
|
||
| File | Change |
|
||
|---|---|
|
||
| `backend/src/cyclone/api.py` | Add 6 GET routes; wire parse-837/parse-835 to `store.add()`; import new module |
|
||
| `backend/src/cyclone/__main__.py` | Bind uvicorn to `127.0.0.1:8000`; honor `CYCLONE_PORT` + `CYCLONE_RELOAD` env vars |
|
||
|
||
### New frontend files
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `src/components/ui/skeleton.tsx` | `<Skeleton variant="row" \| "card" \| "kpi" />` with hairline + scanline-shimmer |
|
||
| `src/components/ui/empty-state.tsx` | `<EmptyState eyebrow="…" message="…" />` — all-caps eyebrow, hairline-circle icon |
|
||
| `src/components/ui/error-state.tsx` | `<ErrorState error={err} onRetry={fn} />` — destructive-hairline block + retry |
|
||
| `src/components/ui/filter-chips.tsx` | `<FilterChips active={…} onRemove={…} onClearAll={…} />` |
|
||
| `src/components/ui/pagination.tsx` | `<Pagination page={n} total={n} onChange={fn} />` with `nav-active` echo |
|
||
| `src/hooks/useBatches.ts` | `useBatches()` |
|
||
| `src/hooks/useClaims.ts` | `useClaims(params)` — falls back to zustand when `!api.isConfigured` |
|
||
| `src/hooks/useRemittances.ts` | `useRemittances(params)` |
|
||
| `src/hooks/useProviders.ts` | `useProviders(params)` |
|
||
| `src/hooks/useActivity.ts` | `useActivity(params)` |
|
||
| `src/hooks/useParse.ts` | `useParse(kind)` — `useMutation` that invalidates 5 query keys on success |
|
||
| `src/lib/api.test.ts` | ~3 tests: listClaims URL, listBatches path, getBatch kind handling |
|
||
|
||
### Modified frontend files
|
||
| File | Change |
|
||
|---|---|
|
||
| `package.json` | Add `@tanstack/react-query` ^5.59.0 |
|
||
| `tailwind.config.js` | Add 3 keyframes (`shimmer`, `scan`, `row-flash`) |
|
||
| `src/main.tsx` | Wrap `<App />` in `<QueryClientProvider>` |
|
||
| `src/components/Layout.tsx` | Add `useIsFetching()` refetch indicator at top of `<main>` |
|
||
| `src/lib/api.ts` | Add `listBatches`, `getBatch`, `listClaims`, `listRemittances`, `listProviders`, `listActivity`; replace `legacyNotImplemented` for those with real fetches |
|
||
| `src/pages/Claims.tsx` | Use `useClaims`; add `Skeleton` (5 rows), `EmptyState` ("Claims · inbox idle"), `ErrorState`, `FilterChips`, `Pagination`, `data-newer-than` flash |
|
||
| `src/pages/Remittances.tsx` | Use `useRemittances`; same UX patterns |
|
||
| `src/pages/Providers.tsx` | Use `useProviders`; `Skeleton` variant=`card` |
|
||
| `src/pages/ActivityLog.tsx` | Use `useActivity`; `refetchInterval: 30_000`; `EmptyState` ("Activity · log idle") |
|
||
| `src/pages/Upload.tsx` | Replace inline `addParsedBatch` flow with `useParse(kind)` mutation |
|
||
|
||
### New docs files
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `docs/reference/837p.md` | ~80 lines — 837P structure + CO Medicaid specifics |
|
||
| `docs/reference/835.md` | ~80 lines — 835 structure + balancing + CO Medicaid specifics |
|
||
| `docs/reference/x12naming.md` | ~50 lines — segments, elements, composites, loops, common qualifiers |
|
||
| `docs/reference/co-medicaid.md` | ~80 lines — TPID, payer IDs, allowed codes, POS codes |
|
||
|
||
### Modified docs files
|
||
| File | Change |
|
||
|---|---|
|
||
| `README.md` (root) | Rewrite: what, install, dev, test, layout, roadmap, license note |
|
||
|
||
---
|
||
|
||
## Phase 1 — Backend
|
||
|
||
### Task 1: `cyclone.store` module — `BatchRecord` + empty `InMemoryStore`
|
||
|
||
**Files:**
|
||
- Create: `backend/src/cyclone/store.py`
|
||
- Create: `backend/tests/test_store.py`
|
||
|
||
- [x] **Step 1: Write the failing test for `BatchRecord` construction + module import**
|
||
|
||
`backend/tests/test_store.py`:
|
||
```python
|
||
"""Tests for the in-memory batch store."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from cyclone.store import BatchRecord, InMemoryStore, store
|
||
from cyclone.parsers.models import ParseResult, Envelope, ClaimOutput, ValidationReport
|
||
|
||
|
||
def _make_claim(claim_id: str = "CLM-1") -> ClaimOutput:
|
||
"""Minimal ClaimOutput stub for store tests. Real parser not needed here."""
|
||
return ClaimOutput(
|
||
claim_id=claim_id,
|
||
billing_provider={"npi": "1234567890", "name": "Test", "taxonomy": None,
|
||
"address": None, "city": None, "state": None, "zip": None,
|
||
"phone": None, "tax_id": None},
|
||
subscriber={"member_id": "M1", "first_name": "Jane", "last_name": "Doe",
|
||
"dob": None, "gender": None, "address": None, "city": None,
|
||
"state": None, "zip": None},
|
||
payer={"id": "P1", "name": "Test Payer", "type": None, "address": None,
|
||
"city": None, "state": None, "zip": None},
|
||
diagnoses=[],
|
||
service_lines=[],
|
||
claim={"total_charge": 0.0, "place_of_service": "11",
|
||
"frequency_code": "1", "prior_auth": None, "provider_signature": None,
|
||
"assignment": None, "benefits_assigned": None, "release_of_info": None},
|
||
validation=ValidationReport(passed=True, errors=[], warnings=[]),
|
||
raw_segments=[],
|
||
)
|
||
|
||
|
||
def _make_result() -> ParseResult:
|
||
return ParseResult(
|
||
envelope=Envelope(
|
||
sender_id="SENDER", sender_qualifier="ZZ",
|
||
receiver_id="RECEIVER", receiver_qualifier="ZZ",
|
||
control_number="0001", usage_indicator="P",
|
||
transaction_set="837", transaction_date="2026-06-19",
|
||
transaction_time=None,
|
||
),
|
||
billing_provider={"npi": "1234567890", "name": "Test", "taxonomy": None,
|
||
"address": None, "city": None, "state": None, "zip": None,
|
||
"phone": None, "tax_id": None},
|
||
claims=[_make_claim("CLM-1")],
|
||
summary={"total_claims": 1, "passed": 1, "failed": 0,
|
||
"started_at": "2026-06-19T12:00:00Z",
|
||
"finished_at": "2026-06-19T12:00:01Z",
|
||
"duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
|
||
)
|
||
|
||
|
||
def test_module_singleton_store_exists():
|
||
"""The module-level `store` should be importable and be an InMemoryStore."""
|
||
assert isinstance(store, InMemoryStore)
|
||
|
||
|
||
def test_in_memory_store_starts_empty():
|
||
s = InMemoryStore()
|
||
assert s.list() == []
|
||
|
||
|
||
def test_add_and_get_round_trip():
|
||
s = InMemoryStore()
|
||
result = _make_result()
|
||
rec = BatchRecord(
|
||
id="abc123",
|
||
kind="837p",
|
||
input_filename="test.txt",
|
||
parsed_at="2026-06-19T12:00:00Z",
|
||
result=result,
|
||
)
|
||
s.add(rec)
|
||
assert s.get("abc123") is rec
|
||
assert s.list() == [rec]
|
||
|
||
|
||
def test_get_missing_returns_none():
|
||
s = InMemoryStore()
|
||
assert s.get("does-not-exist") is None
|
||
```
|
||
|
||
> Note: if your `ClaimOutput` / `Envelope` / `ValidationReport` / `ParseResult` model fields differ, adjust the `_make_claim` / `_make_result` stubs to match the real field names. Run `python -c "from cyclone.parsers.models import ClaimOutput; help(ClaimOutput)"` to confirm.
|
||
|
||
- [x] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_store.py -v`
|
||
Expected: `ModuleNotFoundError: No module named 'cyclone.store'` (or similar import error).
|
||
|
||
- [x] **Step 3: Implement `BatchRecord` + empty `InMemoryStore` (no mappers yet)**
|
||
|
||
`backend/src/cyclone/store.py`:
|
||
```python
|
||
"""In-memory batch store for parsed X12 files.
|
||
|
||
Lives at module scope so route handlers and tests can import a single
|
||
singleton. Single-process only — no cross-process coordination. Threading:
|
||
a re-entrant lock guards every public method so FastAPI's threadpool
|
||
request handlers can call them concurrently.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
from datetime import datetime, timezone
|
||
from typing import Iterable, Literal
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
from cyclone.parsers.models import ParseResult
|
||
from cyclone.parsers.models_835 import ParseResult835
|
||
|
||
|
||
BatchKind = Literal["837p", "835"]
|
||
|
||
|
||
class BatchRecord(BaseModel):
|
||
"""One parsed file, with a stable uuid4 id and the full ParseResult."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
id: str
|
||
kind: BatchKind
|
||
input_filename: str
|
||
parsed_at: str # ISO 8601 UTC, e.g. "2026-06-19T12:00:00Z"
|
||
result: ParseResult | ParseResult835 = Field(discriminator=None)
|
||
|
||
|
||
class InMemoryStore:
|
||
"""Thread-safe list of BatchRecord, newest-first on `list`."""
|
||
|
||
def __init__(self) -> None:
|
||
self._batches: list[BatchRecord] = []
|
||
self._lock = threading.RLock()
|
||
|
||
def add(self, record: BatchRecord) -> None:
|
||
with self._lock:
|
||
self._batches.append(record)
|
||
|
||
def list(self, *, limit: int = 100) -> list[BatchRecord]:
|
||
with self._lock:
|
||
# Newest first.
|
||
return list(reversed(self._batches[-limit:]))
|
||
|
||
def get(self, batch_id: str) -> BatchRecord | None:
|
||
with self._lock:
|
||
for b in self._batches:
|
||
if b.id == batch_id:
|
||
return b
|
||
return None
|
||
|
||
def all(self) -> list[BatchRecord]:
|
||
with self._lock:
|
||
return list(self._batches)
|
||
|
||
|
||
store = InMemoryStore()
|
||
|
||
|
||
def utcnow_iso() -> str:
|
||
"""ISO 8601 UTC timestamp, second precision, suffixed with 'Z'."""
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
```
|
||
|
||
> Note: the imports `from cyclone.parsers.models import ParseResult` and `from cyclone.parsers.models_835 import ParseResult835` are correct based on the existing project layout. If the Pydantic v2 union complains about the discriminator, drop the `discriminator=None` and let Pydantic use the default behavior — the actual class is determined at runtime by the field type.
|
||
|
||
- [x] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_store.py -v`
|
||
Expected: all 4 tests pass.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/store.py backend/tests/test_store.py
|
||
git commit -m "feat(backend): add cyclone.store with BatchRecord and InMemoryStore"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Store mappers — `to_ui_claim`, `to_ui_remittance`, `to_ui_provider`, `to_activity_event`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/store.py`
|
||
- Modify: `backend/tests/test_store.py`
|
||
|
||
- [x] **Step 1: Add failing tests for the mappers**
|
||
|
||
Append to `backend/tests/test_store.py`:
|
||
```python
|
||
def test_to_ui_claim_maps_status_submitted():
|
||
from cyclone.store import to_ui_claim
|
||
claim = _make_claim("CLM-1")
|
||
out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||
assert out["id"] == "CLM-1"
|
||
assert out["status"] == "submitted" # passed=True, frequency_code="1"
|
||
assert out["providerNpi"] == "1234567890"
|
||
assert out["batchId"] == "b1"
|
||
|
||
|
||
def test_to_ui_claim_maps_status_pending_when_warnings():
|
||
from cyclone.store import to_ui_claim
|
||
claim = _make_claim("CLM-2")
|
||
claim.validation = ValidationReport(
|
||
passed=True, errors=[],
|
||
warnings=[{"rule": "W001", "message": "soft"}],
|
||
)
|
||
out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||
assert out["status"] == "pending"
|
||
|
||
|
||
def test_to_ui_claim_maps_status_denied_when_failed():
|
||
from cyclone.store import to_ui_claim
|
||
claim = _make_claim("CLM-3")
|
||
claim.validation = ValidationReport(
|
||
passed=False,
|
||
errors=[{"rule": "E001", "message": "hard"}],
|
||
warnings=[],
|
||
)
|
||
out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||
assert out["status"] == "denied"
|
||
|
||
|
||
def test_to_ui_remittance_maps_status_received_for_paid():
|
||
from cyclone.parsers.models_835 import (
|
||
ClaimPayment, ClaimFilingIndicator, ParseResult835,
|
||
Payer835, Payee835, Envelope as Envelope835, FinancialInfo,
|
||
ReassociationTrace,
|
||
)
|
||
from cyclone.store import to_ui_remittance
|
||
cp = ClaimPayment(
|
||
payer_claim_control_number="PCN-1",
|
||
status_code="1",
|
||
status_label="Processed as Primary",
|
||
total_charge=100.0, total_paid=80.0, total_adjustment=20.0,
|
||
total_patient_responsibility=0.0,
|
||
claim_filing_indicator=ClaimFilingIndicator.MC,
|
||
original_claim_id=None, frequency_code=None, facility_type=None,
|
||
service_payments=[],
|
||
adjustments=[],
|
||
)
|
||
out = to_ui_remittance(cp, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||
assert out["status"] == "received"
|
||
|
||
|
||
def test_to_ui_provider_aggregates_claims():
|
||
from cyclone.store import to_ui_provider
|
||
out = to_ui_provider(
|
||
npi="1234567890", name="Acme Clinic", tax_id="99-9999999",
|
||
address="1 Main", city="Denver", state="CO", zip="80202", phone="303-555-0100",
|
||
claim_count=5, outstanding_ar=250.0,
|
||
)
|
||
assert out["npi"] == "1234567890"
|
||
assert out["claimCount"] == 5
|
||
|
||
|
||
def test_to_activity_event_uses_iso_timestamp():
|
||
from cyclone.store import to_activity_event
|
||
out = to_activity_event(
|
||
id="a1", kind="claim_submitted",
|
||
message="Claim CLM-1 submitted",
|
||
timestamp="2026-06-19T12:00:00Z",
|
||
npi="1234567890", amount=100.0,
|
||
)
|
||
assert out["id"] == "a1"
|
||
assert out["kind"] == "claim_submitted"
|
||
assert out["amount"] == 100.0
|
||
```
|
||
|
||
> Adjust stub values to match your actual Pydantic model field names. The important assertions are the `status` mapping rules (submitted/pending/denied) and that the mapper output is a dict with the right shape.
|
||
|
||
- [x] **Step 2: Run the test to verify it fails**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_store.py -v`
|
||
Expected: `ImportError: cannot import name 'to_ui_claim' from 'cyclone.store'`.
|
||
|
||
- [x] **Step 3: Implement the mappers**
|
||
|
||
Add to `backend/src/cyclone/store.py` (after `utcnow_iso`):
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# Mappers: rich backend models → simpler UI types.
|
||
# The shape of the returned dicts must match the TypeScript interfaces in
|
||
# `src/types/index.ts`. Keep both sides in sync.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def to_ui_claim(
|
||
claim: ClaimOutput,
|
||
*,
|
||
batch_id: str,
|
||
parsed_at: str,
|
||
) -> dict:
|
||
"""Map a 837P ClaimOutput to the UI's `Claim` shape.
|
||
|
||
Status rules (per spec section 6.2):
|
||
- passed and frequency_code == "1" → submitted
|
||
- passed and any warnings → pending
|
||
- !passed → denied
|
||
- otherwise → draft
|
||
"""
|
||
v = claim.validation
|
||
if v.passed and claim.claim.frequency_code == "1":
|
||
status = "submitted"
|
||
elif v.passed and v.warnings:
|
||
status = "pending"
|
||
elif not v.passed:
|
||
status = "denied"
|
||
else:
|
||
status = "draft"
|
||
|
||
return {
|
||
"id": claim.claim_id,
|
||
"patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(),
|
||
"providerNpi": claim.billing_provider.npi,
|
||
"payerName": claim.payer.name,
|
||
"cptCode": (
|
||
claim.service_lines[0].procedure.code
|
||
if claim.service_lines
|
||
else ""
|
||
),
|
||
"billedAmount": float(claim.claim.total_charge or 0.0),
|
||
"receivedAmount": 0.0, # populated by 835 reconciliation (sub-project 2)
|
||
"status": status,
|
||
"denialReason": None,
|
||
"submissionDate": parsed_at, # batch-level timestamp; close enough
|
||
"batchId": batch_id,
|
||
"parsedAt": parsed_at,
|
||
}
|
||
|
||
|
||
def to_ui_remittance(
|
||
cp: ClaimPayment, # type: ignore[name-defined] # noqa: F821
|
||
*,
|
||
batch_id: str,
|
||
parsed_at: str,
|
||
) -> dict:
|
||
"""Map an 835 ClaimPayment to the UI's `Remittance` shape.
|
||
|
||
Status rules (per spec section 6.2):
|
||
- CLP02 in {1, 2, 19, 20} → received (paid, primary/secondary)
|
||
- CLP02 == 4 → received (denied; denial reason from CAS)
|
||
- CLP02 in {21, 22} → reconciled (reversal of prior payment)
|
||
- any other valid code → received
|
||
"""
|
||
code = cp.status_code
|
||
if code in {"21", "22"}:
|
||
status = "reconciled"
|
||
else:
|
||
status = "received" # paid, denied, voided, etc. — all "received" until
|
||
# 837P↔835 reconciliation sets a more specific value (sub-project 2)
|
||
|
||
return {
|
||
"id": cp.payer_claim_control_number,
|
||
"claimId": cp.original_claim_id or "",
|
||
"payerName": "", # filled by caller with batch-level payer name
|
||
"paidAmount": float(cp.total_paid or 0.0),
|
||
"adjustmentAmount": float(cp.total_adjustment or 0.0),
|
||
"status": status,
|
||
"denialReason": None,
|
||
"receivedDate": parsed_at,
|
||
"batchId": batch_id,
|
||
"parsedAt": parsed_at,
|
||
}
|
||
|
||
|
||
def to_ui_provider(
|
||
*,
|
||
npi: str,
|
||
name: str,
|
||
tax_id: str | None = None,
|
||
address: str | None = None,
|
||
city: str | None = None,
|
||
state: str | None = None,
|
||
zip: str | None = None,
|
||
phone: str | None = None,
|
||
claim_count: int = 0,
|
||
outstanding_ar: float = 0.0,
|
||
) -> dict:
|
||
return {
|
||
"npi": npi,
|
||
"name": name,
|
||
"taxId": tax_id or "",
|
||
"address": address or "",
|
||
"city": city or "",
|
||
"state": state or "",
|
||
"zip": zip or "",
|
||
"phone": phone or "",
|
||
"claimCount": claim_count,
|
||
"outstandingAr": float(outstanding_ar),
|
||
}
|
||
|
||
|
||
def to_activity_event(
|
||
*,
|
||
id: str,
|
||
kind: str,
|
||
message: str,
|
||
timestamp: str,
|
||
npi: str | None = None,
|
||
amount: float | None = None,
|
||
) -> dict:
|
||
return {
|
||
"id": id,
|
||
"kind": kind,
|
||
"message": message,
|
||
"timestamp": timestamp,
|
||
"npi": npi,
|
||
"amount": amount,
|
||
}
|
||
```
|
||
|
||
> You'll need to add the missing imports at the top of `store.py`:
|
||
> ```python
|
||
> from cyclone.parsers.models import ClaimOutput, ParseResult
|
||
> ```
|
||
> (already imported in Task 1; the `ClaimPayment` import for `to_ui_remittance` should be added:
|
||
> ```python
|
||
> from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
||
> ```)
|
||
|
||
- [x] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_store.py -v`
|
||
Expected: all 10 tests pass.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/store.py backend/tests/test_store.py
|
||
git commit -m "feat(backend): add store mappers (claim, remittance, provider, activity)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Store iterators — `iter_claims`, `iter_remittances`, `distinct_providers`, `recent_activity`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/store.py`
|
||
- Modify: `backend/tests/test_store.py`
|
||
|
||
- [x] **Step 1: Add failing tests for the iterators**
|
||
|
||
Append to `backend/tests/test_store.py`:
|
||
```python
|
||
def test_iter_claims_filters_by_status():
|
||
from cyclone.store import to_ui_claim
|
||
s = InMemoryStore()
|
||
a = _make_claim("A")
|
||
b = _make_claim("B")
|
||
b.validation = ValidationReport(passed=True, errors=[],
|
||
warnings=[{"rule": "W1", "message": "x"}])
|
||
s.add(BatchRecord(id="1", kind="837p", input_filename="a.txt",
|
||
parsed_at="2026-06-19T12:00:00Z", result=_make_result()))
|
||
out = list(s.iter_claims(status="submitted"))
|
||
assert all(c["status"] == "submitted" for c in out)
|
||
# B's status is "pending" (passed=True, has warnings), so it should be excluded
|
||
|
||
|
||
def test_iter_claims_filters_by_batch_id():
|
||
from cyclone.store import to_ui_claim
|
||
s = InMemoryStore()
|
||
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
|
||
parsed_at="2026-06-19T12:00:00Z", result=_make_result()))
|
||
out = list(s.iter_claims(batch_id="b1"))
|
||
assert all(c["batchId"] == "b1" for c in out)
|
||
|
||
|
||
def test_iter_claims_sorts_by_billed_amount_desc():
|
||
s = InMemoryStore()
|
||
result = _make_result()
|
||
result.claims = [
|
||
_make_claim("cheap"),
|
||
_make_claim("expensive"),
|
||
]
|
||
result.claims[0].claim.total_charge = 50.0
|
||
result.claims[1].claim.total_charge = 500.0
|
||
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
|
||
parsed_at="2026-06-19T12:00:00Z", result=result))
|
||
out = list(s.iter_claims(sort="billedAmount", order="desc"))
|
||
assert out[0]["id"] == "expensive"
|
||
assert out[1]["id"] == "cheap"
|
||
|
||
|
||
def test_distinct_providers_aggregates_claims():
|
||
s = InMemoryStore()
|
||
result = _make_result()
|
||
result.claims = [_make_claim("A"), _make_claim("B")]
|
||
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
|
||
parsed_at="2026-06-19T12:00:00Z", result=result))
|
||
out = s.distinct_providers()
|
||
assert len(out) == 1
|
||
assert out[0]["npi"] == "1234567890"
|
||
assert out[0]["claimCount"] == 2
|
||
|
||
|
||
def test_recent_activity_returns_newest_first():
|
||
s = InMemoryStore()
|
||
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
|
||
parsed_at="2026-06-19T11:00:00Z", result=_make_result()))
|
||
s.add(BatchRecord(id="b2", kind="837p", input_filename="b.txt",
|
||
parsed_at="2026-06-19T12:00:00Z", result=_make_result()))
|
||
out = s.recent_activity(limit=10)
|
||
assert out[0]["timestamp"] == "2026-06-19T12:00:00Z"
|
||
assert out[1]["timestamp"] == "2026-06-19T11:00:00Z"
|
||
```
|
||
|
||
- [x] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_store.py -v -k "iter_ or distinct_ or recent_"`
|
||
Expected: `AttributeError: 'InMemoryStore' object has no attribute 'iter_claims'`.
|
||
|
||
- [x] **Step 3: Implement the iterators**
|
||
|
||
Add to `backend/src/cyclone/store.py` (as methods on `InMemoryStore`):
|
||
```python
|
||
def iter_claims(
|
||
self,
|
||
*,
|
||
batch_id: str | None = None,
|
||
status: str | None = None,
|
||
provider_npi: str | None = None,
|
||
date_from: str | None = None,
|
||
date_to: str | None = None,
|
||
sort: str | None = None,
|
||
order: str = "desc",
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
) -> Iterable[dict]:
|
||
with self._lock:
|
||
all_claims: list[dict] = []
|
||
for b in self._batches:
|
||
if b.kind != "837p":
|
||
continue
|
||
if batch_id is not None and b.id != batch_id:
|
||
continue
|
||
for c in b.result.claims: # type: ignore[attr-defined]
|
||
all_claims.append(to_ui_claim(
|
||
c, batch_id=b.id, parsed_at=b.parsed_at,
|
||
))
|
||
if status is not None:
|
||
all_claims = [c for c in all_claims if c["status"] == status]
|
||
if provider_npi is not None:
|
||
all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi]
|
||
if sort is not None:
|
||
all_claims.sort(
|
||
key=lambda c: c.get(sort, 0) or 0,
|
||
reverse=(order == "desc"),
|
||
)
|
||
yield from all_claims[offset:offset + limit]
|
||
|
||
def iter_remittances(
|
||
self,
|
||
*,
|
||
batch_id: str | None = None,
|
||
payer: str | None = None,
|
||
claim_id: str | None = None,
|
||
date_from: str | None = None,
|
||
date_to: str | None = None,
|
||
sort: str | None = None,
|
||
order: str = "desc",
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
) -> Iterable[dict]:
|
||
with self._lock:
|
||
all_remits: list[dict] = []
|
||
for b in self._batches:
|
||
if b.kind != "835":
|
||
continue
|
||
if batch_id is not None and b.id != batch_id:
|
||
continue
|
||
for cp in b.result.claim_payments: # type: ignore[attr-defined]
|
||
all_remits.append(to_ui_remittance(
|
||
cp, batch_id=b.id, parsed_at=b.parsed_at,
|
||
))
|
||
if payer is not None:
|
||
all_remits = [r for r in all_remits if r.get("payerName") == payer]
|
||
if claim_id is not None:
|
||
all_remits = [r for r in all_remits if r["claimId"] == claim_id]
|
||
if sort is not None:
|
||
all_remits.sort(
|
||
key=lambda r: r.get(sort, 0) or 0,
|
||
reverse=(order == "desc"),
|
||
)
|
||
yield from all_remits[offset:offset + limit]
|
||
|
||
def distinct_providers(self) -> list[dict]:
|
||
with self._lock:
|
||
by_npi: dict[str, dict] = {}
|
||
for b in self._batches:
|
||
if b.kind != "837p":
|
||
continue
|
||
bp = b.result.billing_provider # type: ignore[attr-defined]
|
||
npi = bp.npi
|
||
if npi not in by_npi:
|
||
by_npi[npi] = to_ui_provider(
|
||
npi=npi,
|
||
name=bp.name or "",
|
||
tax_id=bp.tax_id,
|
||
address=bp.address,
|
||
city=bp.city,
|
||
state=bp.state,
|
||
zip=bp.zip,
|
||
phone=bp.phone,
|
||
claim_count=0,
|
||
outstanding_ar=0.0,
|
||
)
|
||
by_npi[npi]["claimCount"] += len(b.result.claims) # type: ignore[attr-defined]
|
||
return list(by_npi.values())
|
||
|
||
def recent_activity(self, *, limit: int = 200) -> list[dict]:
|
||
with self._lock:
|
||
events: list[dict] = []
|
||
for b in reversed(self._batches):
|
||
if b.kind == "837p":
|
||
for c in b.result.claims: # type: ignore[attr-defined]
|
||
events.append(to_activity_event(
|
||
id=f"{b.id}:{c.claim_id}",
|
||
kind="claim_submitted",
|
||
message=f"Claim {c.claim_id} submitted · {c.payer.name}",
|
||
timestamp=b.parsed_at,
|
||
npi=c.billing_provider.npi,
|
||
amount=float(c.claim.total_charge or 0.0),
|
||
))
|
||
elif b.kind == "835":
|
||
for cp in b.result.claim_payments: # type: ignore[attr-defined]
|
||
events.append(to_activity_event(
|
||
id=f"{b.id}:{cp.payer_claim_control_number}",
|
||
kind="remit_received",
|
||
message=f"Remit {cp.payer_claim_control_number} received",
|
||
timestamp=b.parsed_at,
|
||
amount=float(cp.total_paid or 0.0),
|
||
))
|
||
return events[:limit]
|
||
```
|
||
|
||
- [x] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_store.py -v`
|
||
Expected: all tests pass (15 total now).
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/store.py backend/tests/test_store.py
|
||
git commit -m "feat(backend): add store iterators (claims, remits, providers, activity)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Wire `parse-837` and `parse-835` to call `store.add()` on success
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Create: `backend/tests/test_api_parse_persists.py`
|
||
|
||
- [x] **Step 1: Add failing tests**
|
||
|
||
`backend/tests/test_api_parse_persists.py`:
|
||
```python
|
||
"""Successful parses must persist to the store; failed parses must not."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from cyclone.api import app
|
||
from cyclone.store import store as global_store
|
||
|
||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def clear_store():
|
||
"""Reset the module-level store before and after each test."""
|
||
with global_store._lock:
|
||
global_store._batches.clear()
|
||
yield
|
||
with global_store._lock:
|
||
global_store._batches.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def client() -> TestClient:
|
||
return TestClient(app)
|
||
|
||
|
||
def test_successful_parse_837_creates_batch(client: TestClient):
|
||
assert len(global_store.list()) == 0
|
||
text = FIXTURE.read_text()
|
||
resp = client.post(
|
||
"/api/parse-837",
|
||
files={"file": ("test.txt", text, "text/plain")},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert len(global_store.list()) == 1
|
||
rec = global_store.list()[0]
|
||
assert rec.kind == "837p"
|
||
assert rec.input_filename == "test.txt"
|
||
|
||
|
||
def test_failed_parse_837_does_not_create_batch(client: TestClient):
|
||
"""An empty / garbage file should NOT create a batch."""
|
||
assert len(global_store.list()) == 0
|
||
resp = client.post(
|
||
"/api/parse-837",
|
||
files={"file": ("garbage.txt", "not-edi", "text/plain")},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
# Either a 4xx from the parser, or a 200 with 0 claims — the contract
|
||
# is that NO batch is added either way.
|
||
assert resp.status_code in (200, 400, 422)
|
||
assert len(global_store.list()) == 0
|
||
```
|
||
|
||
- [x] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v`
|
||
Expected: first test fails because `len(global_store.list()) == 1` is False (no batches added yet).
|
||
|
||
- [x] **Step 3: Modify `cyclone.api` to call `store.add()` on success**
|
||
|
||
In `backend/src/cyclone/api.py`, add the import near the top:
|
||
```python
|
||
from cyclone.store import BatchRecord, store, utcnow_iso
|
||
```
|
||
|
||
Then find the existing `POST /api/parse-837` route handler (the one that returns a `ParseResult`) and add the `store.add()` call right before the return statement. The structure should be:
|
||
|
||
```python
|
||
@app.post("/api/parse-837")
|
||
async def parse_837_endpoint(
|
||
file: UploadFile = File(...),
|
||
payer: str = Query("co_medicaid"),
|
||
strict: bool = Query(False),
|
||
include_raw_segments: bool = Query(True),
|
||
request: Request = None,
|
||
):
|
||
# ... existing logic that produces `result: ParseResult` ...
|
||
# Right before returning the JSONResponse, add:
|
||
if result is not None:
|
||
rec = BatchRecord(
|
||
id=uuid.uuid4().hex,
|
||
kind="837p",
|
||
input_filename=file.filename or "upload.txt",
|
||
parsed_at=utcnow_iso(),
|
||
result=result,
|
||
)
|
||
store.add(rec)
|
||
# ... return as before
|
||
```
|
||
|
||
Repeat for `parse_835_endpoint`, with `kind="835"` and the result cast to `ParseResult835`.
|
||
|
||
Add the `uuid` import at the top of `api.py`:
|
||
```python
|
||
import uuid
|
||
```
|
||
|
||
> The exact location depends on your existing parse-route structure. The key invariant is: `store.add(record)` is called only after the parser successfully produces a result, and *before* the response is returned.
|
||
|
||
- [x] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v`
|
||
Expected: both tests pass.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py
|
||
git commit -m "feat(backend): wire parse-837/parse-835 to persist to InMemoryStore"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `GET /api/batches` and `GET /api/batches/{id}` routes
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Modify: `backend/tests/test_api_gets.py` (new)
|
||
|
||
- [x] **Step 1: Add failing tests for the batch endpoints**
|
||
|
||
`backend/tests/test_api_gets.py`:
|
||
```python
|
||
"""Tests for the 6 new GET endpoints."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from cyclone.api import app
|
||
from cyclone.store import store as global_store
|
||
|
||
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def clear_store():
|
||
with global_store._lock:
|
||
global_store._batches.clear()
|
||
yield
|
||
with global_store._lock:
|
||
global_store._batches.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def client() -> TestClient:
|
||
return TestClient(app)
|
||
|
||
|
||
@pytest.fixture
|
||
def seeded_store():
|
||
"""One parsed 837P fixture batch."""
|
||
text = FIXTURE_837.read_text()
|
||
client = TestClient(app)
|
||
client.post(
|
||
"/api/parse-837",
|
||
files={"file": ("x.txt", text, "text/plain")},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
return client
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# /api/batches
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def test_batches_returns_empty_list_when_no_parses(client: TestClient):
|
||
resp = client.get("/api/batches")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body == {"items": [], "total": 0, "returned": 0, "has_more": False}
|
||
|
||
|
||
def test_batches_returns_summary_after_parse(seeded_store):
|
||
resp = seeded_store.get("/api/batches")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["total"] == 1
|
||
assert body["returned"] == 1
|
||
assert body["items"][0]["kind"] == "837p"
|
||
assert body["items"][0]["inputFilename"] == "x.txt"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# /api/batches/{id}
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def test_get_batch_by_id_returns_full_result(seeded_store):
|
||
batches = seeded_store.get("/api/batches").json()["items"]
|
||
bid = batches[0]["id"]
|
||
resp = seeded_store.get(f"/api/batches/{bid}")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert "envelope" in body
|
||
assert "claims" in body
|
||
assert len(body["claims"]) == 2
|
||
|
||
|
||
def test_get_batch_404_when_missing(client: TestClient):
|
||
resp = client.get("/api/batches/does-not-exist")
|
||
assert resp.status_code == 404
|
||
```
|
||
|
||
- [x] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v`
|
||
Expected: all 4 tests fail with 404 (routes don't exist yet).
|
||
|
||
- [x] **Step 3: Implement the routes**
|
||
|
||
Add to `backend/src/cyclone/api.py`:
|
||
```python
|
||
from datetime import datetime
|
||
|
||
|
||
@app.get("/api/batches")
|
||
def list_batches(limit: int = Query(100, ge=1, le=1000)):
|
||
"""Summary of all parsed batches, newest first."""
|
||
records = store.list(limit=limit)
|
||
items = [
|
||
{
|
||
"id": r.id,
|
||
"kind": r.kind,
|
||
"inputFilename": r.input_filename,
|
||
"parsedAt": r.parsed_at,
|
||
"claimCount": (
|
||
len(r.result.claims) # type: ignore[attr-defined]
|
||
if r.kind == "837p"
|
||
else len(r.result.claim_payments) # type: ignore[attr-defined]
|
||
),
|
||
}
|
||
for r in records
|
||
]
|
||
return {
|
||
"items": items,
|
||
"total": len(store.all()),
|
||
"returned": len(items),
|
||
"has_more": len(store.all()) > len(items),
|
||
}
|
||
|
||
|
||
@app.get("/api/batches/{batch_id}")
|
||
def get_batch(batch_id: str):
|
||
rec = store.get(batch_id)
|
||
if rec is None:
|
||
raise HTTPException(status_code=404, detail=f"Batch {batch_id} not found")
|
||
return rec.result
|
||
```
|
||
|
||
- [x] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "batches"`
|
||
Expected: 4 tests pass.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
|
||
git commit -m "feat(backend): add GET /api/batches and GET /api/batches/{id}"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `GET /api/claims` with filter / sort / pagination
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Modify: `backend/tests/test_api_gets.py`
|
||
|
||
- [x] **Step 1: Add failing tests**
|
||
|
||
Append to `backend/tests/test_api_gets.py`:
|
||
```python
|
||
# --------------------------------------------------------------------------- #
|
||
# /api/claims
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def test_claims_returns_empty_list_when_no_parses(client: TestClient):
|
||
resp = client.get("/api/claims")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["items"] == []
|
||
assert body["total"] == 0
|
||
|
||
|
||
def test_claims_filters_by_status_submitted(seeded_store):
|
||
resp = seeded_store.get("/api/claims?status=submitted")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert all(c["status"] == "submitted" for c in body["items"])
|
||
assert body["total"] >= 1
|
||
|
||
|
||
def test_claims_filters_by_batch_id(seeded_store):
|
||
bid = seeded_store.get("/api/batches").json()["items"][0]["id"]
|
||
resp = seeded_store.get(f"/api/claims?batch_id={bid}")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert all(c["batchId"] == bid for c in body["items"])
|
||
|
||
|
||
def test_claims_sorts_by_billed_amount_desc(seeded_store):
|
||
resp = seeded_store.get("/api/claims?sort=billedAmount&order=desc")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
amounts = [c["billedAmount"] for c in body["items"]]
|
||
assert amounts == sorted(amounts, reverse=True)
|
||
|
||
|
||
def test_claims_pagination_limit_offset(seeded_store):
|
||
resp1 = seeded_store.get("/api/claims?limit=1&offset=0")
|
||
body1 = resp1.json()
|
||
assert body1["returned"] == 1
|
||
assert body1["has_more"] is True
|
||
```
|
||
|
||
- [x] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "claims_"`
|
||
Expected: 404 / Not Found for `/api/claims`.
|
||
|
||
- [x] **Step 3: Implement the route**
|
||
|
||
Add to `backend/src/cyclone/api.py`:
|
||
```python
|
||
@app.get("/api/claims")
|
||
def list_claims(
|
||
batch_id: str | None = Query(None),
|
||
status: str | None = Query(None),
|
||
provider_npi: str | None = Query(None, alias="provider_npi"),
|
||
payer: str | None = Query(None),
|
||
date_from: str | None = Query(None),
|
||
date_to: str | None = Query(None),
|
||
sort: str | None = Query(None),
|
||
order: str = Query("desc"),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
):
|
||
items = list(store.iter_claims(
|
||
batch_id=batch_id, status=status, provider_npi=provider_npi,
|
||
payer=payer, date_from=date_from, date_to=date_to,
|
||
sort=sort, order=order, limit=limit, offset=offset,
|
||
))
|
||
total = len(list(store.iter_claims(
|
||
batch_id=batch_id, status=status, provider_npi=provider_npi,
|
||
payer=payer, date_from=date_from, date_to=date_to,
|
||
)))
|
||
return {
|
||
"items": items,
|
||
"total": total,
|
||
"returned": len(items),
|
||
"has_more": total > offset + len(items),
|
||
}
|
||
```
|
||
|
||
- [x] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "claims_"`
|
||
Expected: 5 tests pass.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
|
||
git commit -m "feat(backend): add GET /api/claims with filter/sort/pagination"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `GET /api/remittances`, `GET /api/providers`, `GET /api/activity`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Modify: `backend/tests/test_api_gets.py`
|
||
|
||
- [x] **Step 1: Add failing tests**
|
||
|
||
Append to `backend/tests/test_api_gets.py`:
|
||
```python
|
||
# --------------------------------------------------------------------------- #
|
||
# /api/remittances
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def test_remittances_returns_empty_list_when_no_parses(client: TestClient):
|
||
resp = client.get("/api/remittances")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["items"] == []
|
||
|
||
|
||
def test_remittances_filters_by_batch_id(seeded_store):
|
||
"""No 835 fixture parsed yet — but the endpoint should return 200 with empty."""
|
||
resp = seeded_store.get("/api/remittances?batch_id=nope")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["items"] == []
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# /api/providers
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def test_providers_empty_when_no_parses(client: TestClient):
|
||
resp = client.get("/api/providers")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["items"] == []
|
||
|
||
|
||
def test_providers_returns_one_per_distinct_npi(seeded_store):
|
||
resp = seeded_store.get("/api/providers")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert len(body["items"]) == 1
|
||
assert body["items"][0]["npi"] == "1234567890"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# /api/activity
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def test_activity_empty_when_no_parses(client: TestClient):
|
||
resp = client.get("/api/activity")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["items"] == []
|
||
|
||
|
||
def test_activity_returns_one_event_per_claim(seeded_store):
|
||
resp = seeded_store.get("/api/activity")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert len(body["items"]) == 2 # co_medicaid_837p.txt has 2 claims
|
||
assert all(e["kind"] == "claim_submitted" for e in body["items"])
|
||
```
|
||
|
||
- [x] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "remittances_ or providers_ or activity_"`
|
||
Expected: 6 tests fail with 404.
|
||
|
||
- [x] **Step 3: Implement the three routes**
|
||
|
||
Add to `backend/src/cyclone/api.py`:
|
||
```python
|
||
@app.get("/api/remittances")
|
||
def list_remittances(
|
||
batch_id: str | None = Query(None),
|
||
payer: str | None = Query(None),
|
||
claim_id: str | None = Query(None),
|
||
date_from: str | None = Query(None),
|
||
date_to: str | None = Query(None),
|
||
sort: str | None = Query(None),
|
||
order: str = Query("desc"),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
):
|
||
items = list(store.iter_remittances(
|
||
batch_id=batch_id, payer=payer, claim_id=claim_id,
|
||
date_from=date_from, date_to=date_to,
|
||
sort=sort, order=order, limit=limit, offset=offset,
|
||
))
|
||
total = len(list(store.iter_remittances(
|
||
batch_id=batch_id, payer=payer, claim_id=claim_id,
|
||
)))
|
||
return {
|
||
"items": items, "total": total, "returned": len(items),
|
||
"has_more": total > offset + len(items),
|
||
}
|
||
|
||
|
||
@app.get("/api/providers")
|
||
def list_providers(
|
||
npi: str | None = Query(None),
|
||
state: str | None = Query(None),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
):
|
||
items = store.distinct_providers()
|
||
if npi is not None:
|
||
items = [p for p in items if p["npi"] == npi]
|
||
if state is not None:
|
||
items = [p for p in items if p.get("state") == state]
|
||
paged = items[offset:offset + limit]
|
||
return {
|
||
"items": paged, "total": len(items), "returned": len(paged),
|
||
"has_more": len(items) > offset + len(paged),
|
||
}
|
||
|
||
|
||
@app.get("/api/activity")
|
||
def list_activity(
|
||
kind: str | None = Query(None),
|
||
since: str | None = Query(None),
|
||
limit: int = Query(200, ge=1, le=500),
|
||
):
|
||
events = store.recent_activity(limit=limit)
|
||
if kind is not None:
|
||
events = [e for e in events if e["kind"] == kind]
|
||
if since is not None:
|
||
events = [e for e in events if e["timestamp"] >= since]
|
||
return {
|
||
"items": events, "total": len(events), "returned": len(events),
|
||
"has_more": False,
|
||
}
|
||
```
|
||
|
||
- [x] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "remittances_ or providers_ or activity_"`
|
||
Expected: 6 tests pass.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
|
||
git commit -m "feat(backend): add GET /api/{remittances,providers,activity}"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: NDJSON streaming on list endpoints
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Create: `backend/tests/test_api_streaming.py`
|
||
|
||
- [x] **Step 1: Add failing test**
|
||
|
||
`backend/tests/test_api_streaming.py`:
|
||
```python
|
||
"""NDJSON streaming variant on the list endpoints."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from cyclone.api import app
|
||
from cyclone.store import store as global_store
|
||
|
||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def clear_store():
|
||
with global_store._lock:
|
||
global_store._batches.clear()
|
||
yield
|
||
with global_store._lock:
|
||
global_store._batches.clear()
|
||
|
||
|
||
@pytest.fixture
|
||
def seeded_client():
|
||
client = TestClient(app)
|
||
text = FIXTURE.read_text()
|
||
client.post(
|
||
"/api/parse-837",
|
||
files={"file": ("x.txt", text, "text/plain")},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
return client
|
||
|
||
|
||
def test_claims_ndjson_returns_parseable_lines(seeded_client):
|
||
resp = seeded_client.get(
|
||
"/api/claims",
|
||
headers={"Accept": "application/x-ndjson"},
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||
lines = [l for l in resp.text.split("\n") if l.strip()]
|
||
events = [json.loads(l) for l in lines]
|
||
items = [e["data"] for e in events if e["type"] == "item"]
|
||
summary = next(e for e in events if e["type"] == "summary")
|
||
assert len(items) == 2 # co_medicaid_837p.txt has 2 claims
|
||
assert summary["data"]["total"] == 2
|
||
assert summary["data"]["has_more"] is False
|
||
```
|
||
|
||
- [x] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_streaming.py -v`
|
||
Expected: response is JSON, not NDJSON — content-type assertion fails.
|
||
|
||
- [x] **Step 3: Implement streaming on `/api/claims` (and apply the same pattern to remittances/providers/activity)**
|
||
|
||
In `backend/src/cyclone/api.py`, refactor `list_claims` to content-negotiate:
|
||
|
||
```python
|
||
def _ndjson_stream(items: Iterable[dict], total: int) -> Iterator[str]:
|
||
"""Yield NDJSON lines: one `item` per dict, then one `summary`."""
|
||
for it in items:
|
||
yield json.dumps({"type": "item", "data": it}) + "\n"
|
||
yield json.dumps({
|
||
"type": "summary",
|
||
"data": {"total": total, "returned": 0, "has_more": False}, # filled by caller
|
||
}) + "\n"
|
||
|
||
|
||
@app.get("/api/claims")
|
||
def list_claims(
|
||
request: Request,
|
||
batch_id: str | None = Query(None),
|
||
status: str | None = Query(None),
|
||
provider_npi: str | None = Query(None, alias="provider_npi"),
|
||
payer: str | None = Query(None),
|
||
date_from: str | None = Query(None),
|
||
date_to: str | None = Query(None),
|
||
sort: str | None = Query(None),
|
||
order: str = Query("desc"),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
):
|
||
all_filtered = list(store.iter_claims(
|
||
batch_id=batch_id, status=status, provider_npi=provider_npi,
|
||
payer=payer, date_from=date_from, date_to=date_to,
|
||
))
|
||
page = all_filtered[offset:offset + limit]
|
||
total = len(all_filtered)
|
||
has_more = total > offset + len(page)
|
||
|
||
if request.headers.get("accept") == "application/x-ndjson":
|
||
def gen():
|
||
for it in page:
|
||
yield json.dumps({"type": "item", "data": it}) + "\n"
|
||
yield json.dumps({
|
||
"type": "summary",
|
||
"data": {"total": total, "returned": len(page), "has_more": has_more},
|
||
}) + "\n"
|
||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
||
|
||
return {
|
||
"items": page, "total": total, "returned": len(page), "has_more": has_more,
|
||
}
|
||
```
|
||
|
||
Apply the same pattern to `list_remittances`, `list_providers`, and `list_activity` (each branches on `request.headers.get("accept") == "application/x-ndjson"` and returns `StreamingResponse` when set).
|
||
|
||
- [x] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd backend && .venv/bin/pytest tests/test_api_streaming.py -v`
|
||
Expected: test passes.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_streaming.py
|
||
git commit -m "feat(backend): add NDJSON streaming on list endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Bind uvicorn to 127.0.0.1:8000 with env-var support
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/__main__.py`
|
||
|
||
- [x] **Step 1: Update `__main__.py` to bind loopback and honor env vars**
|
||
|
||
Replace `backend/src/cyclone/__main__.py` with:
|
||
```python
|
||
"""Entry point for ``python -m cyclone``.
|
||
|
||
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
|
||
* ``python -m cyclone serve`` — start the FastAPI app on 127.0.0.1:8000
|
||
|
||
Honors the env vars:
|
||
|
||
* ``CYCLONE_PORT`` (default ``8000``)
|
||
* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
|
||
|
||
def main() -> None:
|
||
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||
port = os.environ.get("CYCLONE_PORT", "8000")
|
||
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
||
sys.argv = [
|
||
sys.argv[0],
|
||
"cyclone.api:app",
|
||
"--host", "127.0.0.1",
|
||
"--port", port,
|
||
]
|
||
if reload:
|
||
sys.argv.append("--reload")
|
||
import uvicorn
|
||
|
||
uvicorn.main()
|
||
return
|
||
from cyclone.cli import main as cli_main
|
||
|
||
cli_main()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
- [x] **Step 2: Verify locally (manual)**
|
||
|
||
Run: `cd backend && .venv/bin/python -m cyclone serve &`
|
||
Then: `curl -s http://127.0.0.1:8000/api/health | jq .` → should print `{"status": "ok", "version": "0.1.0"}`.
|
||
Then: `curl -s http://0.0.0.0:8000/api/health` → should fail (refused; loopback only).
|
||
|
||
Kill the server: `kill %1` (or `pkill -f 'cyclone.api:app'`).
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/__main__.py
|
||
git commit -m "feat(backend): bind serve to 127.0.0.1:8000, honor CYCLONE_PORT/CYCLONE_RELOAD"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Run the full backend test suite + manual smoke
|
||
|
||
- [x] **Step 1: Run all backend tests**
|
||
|
||
Run: `cd backend && .venv/bin/pytest -v`
|
||
Expected: all tests pass (existing 121 + new ~23 = 144+ total).
|
||
|
||
- [x] **Step 2: Manual smoke (in two terminals)**
|
||
|
||
Terminal 1: `cd backend && .venv/bin/python -m cyclone serve`
|
||
Terminal 2: `curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_837p.txt" -H "Accept: application/json" http://127.0.0.1:8000/api/parse-837 | jq .summary`
|
||
Then: `curl -s 'http://127.0.0.1:8000/api/claims?status=submitted' | jq '{total, returned}'`
|
||
Then: `curl -s 'http://127.0.0.1:8000/api/claims' -H 'Accept: application/x-ndjson' | head -4`
|
||
Expected: parse succeeds; `/api/claims` returns 2 items; NDJSON returns 2 item lines + 1 summary line.
|
||
|
||
- [x] **Step 3: Commit any leftover changes (likely none)**
|
||
|
||
```bash
|
||
git status # should be clean
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — Frontend
|
||
|
||
### Task 11: Install `@tanstack/react-query`
|
||
|
||
**Files:**
|
||
- Modify: `package.json`
|
||
|
||
- [x] **Step 1: Add the dep**
|
||
|
||
Run: `cd /Users/openclaw/dev/cyclone && npm install @tanstack/react-query@^5.59.0`
|
||
Expected: `package.json` updated; `node_modules` grows.
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json
|
||
git commit -m "feat(frontend): add @tanstack/react-query ^5.59.0"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Wrap `<App />` in `<QueryClientProvider>`
|
||
|
||
**Files:**
|
||
- Modify: `src/main.tsx`
|
||
|
||
- [x] **Step 1: Update `main.tsx`**
|
||
|
||
Replace `src/main.tsx` with:
|
||
```tsx
|
||
import React from "react";
|
||
import ReactDOM from "react-dom/client";
|
||
import { BrowserRouter } from "react-router-dom";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import App from "./App";
|
||
import "./index.css";
|
||
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: {
|
||
queries: {
|
||
staleTime: 30_000,
|
||
refetchOnWindowFocus: true,
|
||
},
|
||
},
|
||
});
|
||
|
||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||
<React.StrictMode>
|
||
<QueryClientProvider client={queryClient}>
|
||
<BrowserRouter>
|
||
<App />
|
||
</BrowserRouter>
|
||
</QueryClientProvider>
|
||
</React.StrictMode>
|
||
);
|
||
```
|
||
|
||
- [x] **Step 2: Verify build still passes**
|
||
|
||
Run: `npm run build`
|
||
Expected: build succeeds with no TS errors.
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/main.tsx
|
||
git commit -m "feat(frontend): wrap App in QueryClientProvider"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Add 3 keyframes to `tailwind.config.js`
|
||
|
||
**Files:**
|
||
- Modify: `tailwind.config.js`
|
||
|
||
- [x] **Step 1: Add the keyframes**
|
||
|
||
In `tailwind.config.js`, inside `theme.extend.keyframes`, add three new entries (next to the existing `fade-in`):
|
||
```js
|
||
"shimmer": {
|
||
"0%": { transform: "translateX(-100%)" },
|
||
"100%": { transform: "translateX(100%)" },
|
||
},
|
||
"scan": {
|
||
"0%": { transform: "translateX(-100%)" },
|
||
"50%": { transform: "translateX(300%)" },
|
||
"100%": { transform: "translateX(-100%)" },
|
||
},
|
||
"row-flash": {
|
||
"0%": { backgroundColor: "hsl(var(--accent) / 0.10)" },
|
||
"100%": { backgroundColor: "transparent" },
|
||
},
|
||
```
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add tailwind.config.js
|
||
git commit -m "feat(frontend): add shimmer, scan, row-flash keyframes"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: `<Skeleton />` UI primitive (spec 7.6)
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/skeleton.tsx`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
`src/components/ui/skeleton.tsx`:
|
||
```tsx
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type SkeletonProps = {
|
||
className?: string;
|
||
variant?: "row" | "card" | "kpi";
|
||
};
|
||
|
||
/**
|
||
* Loading skeleton with a hairline ring + a slow horizontal scanline.
|
||
* Voice: not a generic "pulse a gray block" — the shimmer reads as
|
||
* a precision-instrument sweep.
|
||
*/
|
||
export function Skeleton({ className, variant = "row" }: SkeletonProps) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative overflow-hidden rounded-md bg-muted/30",
|
||
"ring-1 ring-inset ring-border/40",
|
||
className
|
||
)}
|
||
aria-busy="true"
|
||
aria-live="polite"
|
||
>
|
||
<div
|
||
className="absolute inset-0 animate-shimmer"
|
||
style={{
|
||
background:
|
||
"linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
|
||
}}
|
||
/>
|
||
{variant === "row" ? (
|
||
<div className="grid grid-cols-12 gap-3 p-4">
|
||
<div className="col-span-2 h-3 bg-muted/60 rounded" />
|
||
<div className="col-span-3 h-3 bg-muted/60 rounded" />
|
||
<div className="col-span-2 h-3 bg-muted/60 rounded" />
|
||
<div className="col-span-2 h-3 bg-muted/60 rounded" />
|
||
<div className="col-span-2 h-3 bg-muted/60 rounded" />
|
||
<div className="col-span-1 h-3 bg-muted/60 rounded" />
|
||
</div>
|
||
) : variant === "card" ? (
|
||
<div className="p-6 space-y-3">
|
||
<div className="h-4 w-1/2 bg-muted/60 rounded" />
|
||
<div className="h-3 w-1/3 bg-muted/60 rounded" />
|
||
<div className="h-3 w-2/3 bg-muted/60 rounded" />
|
||
</div>
|
||
) : (
|
||
<div className="p-5 space-y-3">
|
||
<div className="h-3 w-1/3 bg-muted/60 rounded" />
|
||
<div className="h-6 w-1/2 bg-muted/60 rounded" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build**
|
||
|
||
Run: `npm run build`
|
||
Expected: passes.
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/ui/skeleton.tsx
|
||
git commit -m "feat(frontend): add Skeleton UI primitive (hairline + scanline-shimmer)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: `<EmptyState />` UI primitive (spec 7.7.2)
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/empty-state.tsx`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
`src/components/ui/empty-state.tsx`:
|
||
```tsx
|
||
import { CircleDashed } from "lucide-react";
|
||
|
||
type EmptyStateProps = {
|
||
/** All-caps instrument label, e.g. "Claims · inbox idle". */
|
||
eyebrow: string;
|
||
/** One-line plain-language message. No apologetic language. */
|
||
message: string;
|
||
};
|
||
|
||
export function EmptyState({ eyebrow, message }: EmptyStateProps) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-2.5 py-14">
|
||
<div className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground">
|
||
<CircleDashed className="h-4 w-4" strokeWidth={1.5} />
|
||
</div>
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||
{eyebrow}
|
||
</div>
|
||
<div className="text-sm text-muted-foreground/80">{message}</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/components/ui/empty-state.tsx
|
||
git commit -m "feat(frontend): add EmptyState primitive (instrument-label eyebrow)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: `<ErrorState />` UI primitive (spec 7.7.3)
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/error-state.tsx`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
`src/components/ui/error-state.tsx`:
|
||
```tsx
|
||
import { AlertCircle } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
|
||
type ErrorStateProps = {
|
||
error: Error;
|
||
onRetry: () => void;
|
||
};
|
||
|
||
export function ErrorState({ error, onRetry }: ErrorStateProps) {
|
||
return (
|
||
<div className="surface rounded-xl border border-destructive/30 p-4 flex items-center gap-3">
|
||
<AlertCircle
|
||
className="h-4 w-4 text-destructive shrink-0"
|
||
strokeWidth={1.75}
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
|
||
Fetch failed
|
||
</div>
|
||
<div className="text-sm text-foreground/90 truncate">
|
||
{error.message}
|
||
</div>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||
Retry
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/components/ui/error-state.tsx
|
||
git commit -m "feat(frontend): add ErrorState primitive (destructive hairline + retry)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: `<FilterChips />` UI primitive (spec 7.7.5)
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/filter-chips.tsx`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
`src/components/ui/filter-chips.tsx`:
|
||
```tsx
|
||
import { X } from "lucide-react";
|
||
|
||
export type FilterChip = { key: string; label: string };
|
||
|
||
type FilterChipsProps = {
|
||
active: FilterChip[];
|
||
onRemove: (key: string) => void;
|
||
onClearAll: () => void;
|
||
};
|
||
|
||
export function FilterChips({
|
||
active,
|
||
onRemove,
|
||
onClearAll,
|
||
}: FilterChipsProps) {
|
||
if (active.length === 0) return null;
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
|
||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||
Active
|
||
</span>
|
||
{active.map((c) => (
|
||
<span
|
||
key={c.key}
|
||
className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-muted/30 pl-2.5 pr-1 py-0.5 text-xs"
|
||
>
|
||
<span className="text-muted-foreground">{c.key}:</span>
|
||
<span className="font-medium">{c.label}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(c.key)}
|
||
className="ml-0.5 h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
|
||
aria-label={`Remove ${c.key} filter`}
|
||
>
|
||
<X className="h-3 w-3" strokeWidth={1.75} />
|
||
</button>
|
||
</span>
|
||
))}
|
||
{active.length > 1 ? (
|
||
<button
|
||
type="button"
|
||
onClick={onClearAll}
|
||
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
Clear all
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/components/ui/filter-chips.tsx
|
||
git commit -m "feat(frontend): add FilterChips primitive (read-only pill row)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: `<Pagination />` UI primitive (spec 7.7.8)
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/pagination.tsx`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
`src/components/ui/pagination.tsx`:
|
||
```tsx
|
||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type PaginationProps = {
|
||
page: number; // 1-indexed
|
||
pageSize: number;
|
||
total: number;
|
||
onChange: (page: number) => void;
|
||
};
|
||
|
||
function buildPageList(current: number, last: number): (number | "…")[] {
|
||
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
|
||
const out: (number | "…")[] = [1];
|
||
const start = Math.max(2, current - 1);
|
||
const end = Math.min(last - 1, current + 1);
|
||
if (start > 2) out.push("…");
|
||
for (let i = start; i <= end; i++) out.push(i);
|
||
if (end < last - 1) out.push("…");
|
||
out.push(last);
|
||
return out;
|
||
}
|
||
|
||
export function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
|
||
const last = Math.max(1, Math.ceil(total / pageSize));
|
||
if (last <= 1) return null;
|
||
const pages = buildPageList(page, last);
|
||
const returned = Math.min(pageSize, total - (page - 1) * pageSize);
|
||
return (
|
||
<div className="flex items-center justify-between gap-3 mt-4 pt-4 border-t border-border/40 text-xs">
|
||
<div className="text-muted-foreground num">
|
||
{returned} of {total}
|
||
</div>
|
||
<nav className="flex items-center gap-1" aria-label="Pagination">
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(Math.max(1, page - 1))}
|
||
disabled={page <= 1}
|
||
className="h-7 w-7 rounded-md hover:bg-muted/60 flex items-center justify-center text-muted-foreground disabled:opacity-40"
|
||
aria-label="Previous page"
|
||
>
|
||
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||
</button>
|
||
{pages.map((p, i) =>
|
||
p === "…" ? (
|
||
<span key={`e-${i}`} className="px-1 text-muted-foreground">
|
||
…
|
||
</span>
|
||
) : (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
onClick={() => onChange(p)}
|
||
aria-current={p === page ? "page" : undefined}
|
||
className={cn(
|
||
"relative h-7 min-w-7 px-2 rounded-md num text-xs flex items-center justify-center transition-colors",
|
||
p === page
|
||
? "bg-muted/60 text-foreground"
|
||
: "hover:bg-muted/40 text-muted-foreground"
|
||
)}
|
||
>
|
||
{p === page ? (
|
||
<span
|
||
aria-hidden
|
||
className="absolute left-0 top-1 bottom-1 w-px bg-accent rounded"
|
||
/>
|
||
) : null}
|
||
{p}
|
||
</button>
|
||
)
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(Math.min(last, page + 1))}
|
||
disabled={page >= last}
|
||
className="h-7 w-7 rounded-md hover:bg-muted/60 flex items-center justify-center text-muted-foreground disabled:opacity-40"
|
||
aria-label="Next page"
|
||
>
|
||
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/components/ui/pagination.tsx
|
||
git commit -m "feat(frontend): add Pagination primitive (nav-active echo, num pages)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Layout-level refetch indicator (spec 7.7.4)
|
||
|
||
**Files:**
|
||
- Modify: `src/components/Layout.tsx`
|
||
|
||
- [x] **Step 1: Update `Layout.tsx`**
|
||
|
||
Replace `src/components/Layout.tsx` with:
|
||
```tsx
|
||
import { Outlet } from "react-router-dom";
|
||
import { useIsFetching } from "@tanstack/react-query";
|
||
import { Sidebar } from "./Sidebar";
|
||
|
||
export function Layout() {
|
||
const isFetching = useIsFetching();
|
||
return (
|
||
<div className="relative min-h-screen z-10">
|
||
{isFetching > 0 ? (
|
||
<div
|
||
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none"
|
||
aria-hidden
|
||
>
|
||
<div
|
||
className="h-full w-1/3 bg-accent animate-scan"
|
||
style={{ boxShadow: "0 0 8px hsl(var(--accent) / 0.5)" }}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<Sidebar />
|
||
<main className="md:pl-60">
|
||
<div className="mx-auto max-w-[1400px] px-8 py-10">
|
||
<Outlet />
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/components/Layout.tsx
|
||
git commit -m "feat(frontend): add 1px scan-line refetch indicator to Layout"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Add GET methods to `src/lib/api.ts`
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/api.ts`
|
||
- Create: `src/lib/api.test.ts` (extend with 3 tests)
|
||
|
||
- [x] **Step 1: Add GET methods + types**
|
||
|
||
In `src/lib/api.ts`, before the `export const api = {` line, add:
|
||
```ts
|
||
// ---------------------------------------------------------------------------
|
||
// GET endpoints (sub-project 1)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export type ListClaimsParams = {
|
||
batch_id?: string;
|
||
status?: string;
|
||
provider_npi?: string;
|
||
payer?: string;
|
||
date_from?: string;
|
||
date_to?: string;
|
||
sort?: string;
|
||
order?: "asc" | "desc";
|
||
limit?: number;
|
||
offset?: number;
|
||
};
|
||
|
||
export type ListRemittancesParams = {
|
||
batch_id?: string;
|
||
payer?: string;
|
||
claim_id?: string;
|
||
date_from?: string;
|
||
date_to?: string;
|
||
sort?: string;
|
||
order?: "asc" | "desc";
|
||
limit?: number;
|
||
offset?: number;
|
||
};
|
||
|
||
export type ListProvidersParams = {
|
||
npi?: string;
|
||
state?: string;
|
||
limit?: number;
|
||
offset?: number;
|
||
};
|
||
|
||
export type ListActivityParams = {
|
||
kind?: string;
|
||
since?: string;
|
||
limit?: number;
|
||
};
|
||
|
||
export type PaginatedResponse<T> = {
|
||
items: T[];
|
||
total: number;
|
||
returned: number;
|
||
has_more: boolean;
|
||
};
|
||
|
||
export type BatchSummary = {
|
||
id: string;
|
||
kind: "837p" | "835";
|
||
inputFilename: string;
|
||
parsedAt: string;
|
||
claimCount: number;
|
||
};
|
||
|
||
function qs(params: Record<string, unknown> | undefined): string {
|
||
if (!params) return "";
|
||
const u = new URLSearchParams();
|
||
for (const [k, v] of Object.entries(params)) {
|
||
if (v === undefined || v === null || v === "") continue;
|
||
u.set(k, String(v));
|
||
}
|
||
const s = u.toString();
|
||
return s ? `?${s}` : "";
|
||
}
|
||
|
||
async function listBatches(): Promise<BatchSummary[]> {
|
||
const res = await fetch(joinUrl("/api/batches"));
|
||
if (!res.ok) {
|
||
const detail = await readErrorBody(res);
|
||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||
}
|
||
const body = (await res.json()) as { items: BatchSummary[] };
|
||
return body.items;
|
||
}
|
||
|
||
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
|
||
const res = await fetch(joinUrl(`/api/batches/${id}`));
|
||
if (!res.ok) {
|
||
const detail = await readErrorBody(res);
|
||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||
}
|
||
return (await res.json()) as ParseResult837 | ParseResult835;
|
||
}
|
||
|
||
async function listClaims<T = unknown>(
|
||
params: ListClaimsParams
|
||
): Promise<PaginatedResponse<T>> {
|
||
const res = await fetch(joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`));
|
||
if (!res.ok) {
|
||
const detail = await readErrorBody(res);
|
||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||
}
|
||
return (await res.json()) as PaginatedResponse<T>;
|
||
}
|
||
|
||
async function listRemittances<T = unknown>(
|
||
params: ListRemittancesParams
|
||
): Promise<PaginatedResponse<T>> {
|
||
const res = await fetch(joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`));
|
||
if (!res.ok) {
|
||
const detail = await readErrorBody(res);
|
||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||
}
|
||
return (await res.json()) as PaginatedResponse<T>;
|
||
}
|
||
|
||
async function listProviders<T = unknown>(
|
||
params: ListProvidersParams = {}
|
||
): Promise<PaginatedResponse<T>> {
|
||
const res = await fetch(joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`));
|
||
if (!res.ok) {
|
||
const detail = await readErrorBody(res);
|
||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||
}
|
||
return (await res.json()) as PaginatedResponse<T>;
|
||
}
|
||
|
||
async function listActivity<T = unknown>(
|
||
params: ListActivityParams = {}
|
||
): Promise<PaginatedResponse<T>> {
|
||
const res = await fetch(joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`));
|
||
if (!res.ok) {
|
||
const detail = await readErrorBody(res);
|
||
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
||
}
|
||
return (await res.json()) as PaginatedResponse<T>;
|
||
```
|
||
|
||
Then update the `export const api = {` block to add the new methods:
|
||
```ts
|
||
export const api = {
|
||
isConfigured,
|
||
baseUrl: BASE_URL,
|
||
health,
|
||
parse837,
|
||
parse835,
|
||
listBatches,
|
||
getBatch,
|
||
listClaims,
|
||
listRemittances,
|
||
listProviders,
|
||
listActivity,
|
||
// Legacy placeholders (removed in a follow-up):
|
||
getClaims: () => legacyNotImplemented("getClaims"),
|
||
createClaim: (_payload: unknown) => legacyNotImplemented("createClaim"),
|
||
getRemittances: () => legacyNotImplemented("getRemittances"),
|
||
getProviders: () => legacyNotImplemented("getProviders"),
|
||
getActivity: () => legacyNotImplemented("getActivity"),
|
||
};
|
||
```
|
||
|
||
- [x] **Step 2: Add 3 unit tests in `src/lib/api.test.ts`**
|
||
|
||
Create `src/lib/api.test.ts`:
|
||
```ts
|
||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||
import { api } from "./api";
|
||
|
||
const originalFetch = global.fetch;
|
||
|
||
describe("api GET helpers", () => {
|
||
beforeEach(() => {
|
||
global.fetch = vi.fn();
|
||
});
|
||
afterEach(() => {
|
||
global.fetch = originalFetch;
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("listBatches hits /api/batches", async () => {
|
||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||
new Response(JSON.stringify({ items: [] }), { status: 200 })
|
||
);
|
||
const out = await api.listBatches();
|
||
expect(out).toEqual([]);
|
||
expect(global.fetch).toHaveBeenCalledWith(
|
||
expect.stringContaining("/api/batches")
|
||
);
|
||
});
|
||
|
||
it("listClaims builds the right URL with status + sort", async () => {
|
||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||
new Response(JSON.stringify({ items: [], total: 0 }), { status: 200 })
|
||
);
|
||
await api.listClaims({ status: "submitted", sort: "billedAmount", order: "desc" });
|
||
const called = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||
expect(called).toContain("/api/claims?");
|
||
expect(called).toContain("status=submitted");
|
||
expect(called).toContain("sort=billedAmount");
|
||
expect(called).toContain("order=desc");
|
||
});
|
||
|
||
it("getBatch returns the result with the right id", async () => {
|
||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||
new Response(
|
||
JSON.stringify({ envelope: null, claims: [], summary: {}, kind: "835" }),
|
||
{ status: 200 }
|
||
)
|
||
);
|
||
const out = await api.getBatch("abc");
|
||
expect(out).toBeTruthy();
|
||
expect(global.fetch).toHaveBeenCalledWith(
|
||
expect.stringContaining("/api/batches/abc")
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
> If `vitest` is not already configured, add `"test": "vitest run"` to `package.json` `scripts` and `vitest` to devDependencies. Run `npm install -D vitest` if needed.
|
||
|
||
- [x] **Step 3: Run the tests**
|
||
|
||
Run: `npm test -- src/lib/api.test.ts`
|
||
Expected: 3 tests pass.
|
||
|
||
- [x] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/lib/api.ts src/lib/api.test.ts package.json package-lock.json
|
||
git commit -m "feat(frontend): add listBatches/getBatch/listClaims/listRemittances/listProviders/listActivity"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: Add 6 query/mutation hooks
|
||
|
||
**Files:**
|
||
- Create: `src/hooks/useBatches.ts`
|
||
- Create: `src/hooks/useClaims.ts`
|
||
- Create: `src/hooks/useRemittances.ts`
|
||
- Create: `src/hooks/useProviders.ts`
|
||
- Create: `src/hooks/useActivity.ts`
|
||
- Create: `src/hooks/useParse.ts`
|
||
|
||
- [x] **Step 1: `useBatches.ts`**
|
||
|
||
```ts
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { api, type BatchSummary } from "@/lib/api";
|
||
|
||
export function useBatches() {
|
||
return useQuery<BatchSummary[]>({
|
||
queryKey: ["batches"],
|
||
queryFn: () => api.listBatches(),
|
||
enabled: api.isConfigured,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: `useClaims.ts`** (with fallback to zustand when `!api.isConfigured`)
|
||
|
||
```ts
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useSyncExternalStore } from "react";
|
||
import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api";
|
||
import { useAppStore } from "@/store";
|
||
import type { Claim } from "@/types";
|
||
|
||
const EMPTY: PaginatedResponse<Claim> = {
|
||
items: [], total: 0, returned: 0, has_more: false,
|
||
};
|
||
|
||
export function useClaims(params: ListClaimsParams) {
|
||
const fallback = useSyncExternalStore(
|
||
(cb) => useAppStore.subscribe(cb),
|
||
() => useAppStore.getState().claims,
|
||
() => useAppStore.getState().claims,
|
||
);
|
||
|
||
const q = useQuery<PaginatedResponse<Claim>>({
|
||
queryKey: ["claims", params],
|
||
queryFn: () => api.listClaims<Claim>(params),
|
||
enabled: api.isConfigured,
|
||
});
|
||
|
||
if (!api.isConfigured) {
|
||
const all = fallback;
|
||
const filtered = all.filter((c) => {
|
||
if (params.status && c.status !== params.status) return false;
|
||
if (params.provider_npi && c.providerNpi !== params.provider_npi) return false;
|
||
if (params.batch_id && c.batchId !== params.batch_id) return false;
|
||
return true;
|
||
});
|
||
const offset = params.offset ?? 0;
|
||
const limit = params.limit ?? 100;
|
||
return {
|
||
data: {
|
||
items: filtered.slice(offset, offset + limit),
|
||
total: filtered.length,
|
||
returned: Math.min(limit, filtered.length - offset),
|
||
has_more: filtered.length > offset + limit,
|
||
} satisfies PaginatedResponse<Claim>,
|
||
isLoading: false,
|
||
isError: false,
|
||
error: null,
|
||
refetch: () => Promise.resolve(),
|
||
} as const;
|
||
}
|
||
return q;
|
||
}
|
||
|
||
export { EMPTY as EMPTY_CLAIMS };
|
||
```
|
||
|
||
- [x] **Step 3: `useRemittances.ts`**
|
||
|
||
```ts
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useSyncExternalStore } from "react";
|
||
import { api, type ListRemittancesParams, type PaginatedResponse } from "@/lib/api";
|
||
import { useAppStore } from "@/store";
|
||
import type { Remittance } from "@/types";
|
||
|
||
export function useRemittances(params: ListRemittancesParams) {
|
||
const fallback = useSyncExternalStore(
|
||
(cb) => useAppStore.subscribe(cb),
|
||
() => useAppStore.getState().remittances,
|
||
() => useAppStore.getState().remittances,
|
||
);
|
||
|
||
const q = useQuery<PaginatedResponse<Remittance>>({
|
||
queryKey: ["remittances", params],
|
||
queryFn: () => api.listRemittances<Remittance>(params),
|
||
enabled: api.isConfigured,
|
||
});
|
||
|
||
if (!api.isConfigured) {
|
||
const offset = params.offset ?? 0;
|
||
const limit = params.limit ?? 100;
|
||
return {
|
||
data: {
|
||
items: fallback.slice(offset, offset + limit),
|
||
total: fallback.length,
|
||
returned: Math.min(limit, fallback.length - offset),
|
||
has_more: fallback.length > offset + limit,
|
||
} satisfies PaginatedResponse<Remittance>,
|
||
isLoading: false, isError: false, error: null,
|
||
refetch: () => Promise.resolve(),
|
||
} as const;
|
||
}
|
||
return q;
|
||
}
|
||
```
|
||
|
||
- [x] **Step 4: `useProviders.ts`**
|
||
|
||
```ts
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useSyncExternalStore } from "react";
|
||
import { api, type ListProvidersParams, type PaginatedResponse } from "@/lib/api";
|
||
import { useAppStore } from "@/store";
|
||
import type { Provider } from "@/types";
|
||
|
||
export function useProviders(params: ListProvidersParams = {}) {
|
||
const fallback = useSyncExternalStore(
|
||
(cb) => useAppStore.subscribe(cb),
|
||
() => useAppStore.getState().providers,
|
||
() => useAppStore.getState().providers,
|
||
);
|
||
|
||
const q = useQuery<PaginatedResponse<Provider>>({
|
||
queryKey: ["providers", params],
|
||
queryFn: () => api.listProviders<Provider>(params),
|
||
enabled: api.isConfigured,
|
||
});
|
||
|
||
if (!api.isConfigured) {
|
||
return {
|
||
data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse<Provider>,
|
||
isLoading: false, isError: false, error: null,
|
||
refetch: () => Promise.resolve(),
|
||
} as const;
|
||
}
|
||
return q;
|
||
}
|
||
```
|
||
|
||
- [x] **Step 5: `useActivity.ts`**
|
||
|
||
```ts
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useSyncExternalStore } from "react";
|
||
import { api, type ListActivityParams, type PaginatedResponse } from "@/lib/api";
|
||
import { useAppStore } from "@/store";
|
||
import type { Activity } from "@/types";
|
||
|
||
export function useActivity(params: ListActivityParams = {}) {
|
||
const fallback = useSyncExternalStore(
|
||
(cb) => useAppStore.subscribe(cb),
|
||
() => useAppStore.getState().activity,
|
||
() => useAppStore.getState().activity,
|
||
);
|
||
|
||
const q = useQuery<PaginatedResponse<Activity>>({
|
||
queryKey: ["activity", params],
|
||
queryFn: () => api.listActivity<Activity>(params),
|
||
enabled: api.isConfigured,
|
||
refetchInterval: api.isConfigured ? 30_000 : false,
|
||
});
|
||
|
||
if (!api.isConfigured) {
|
||
return {
|
||
data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse<Activity>,
|
||
isLoading: false, isError: false, error: null,
|
||
refetch: () => Promise.resolve(),
|
||
} as const;
|
||
}
|
||
return q;
|
||
}
|
||
```
|
||
|
||
- [x] **Step 6: `useParse.ts`**
|
||
|
||
```ts
|
||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { api, type ParseOptions } from "@/lib/api";
|
||
|
||
export function useParse(kind: "837p" | "835") {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: (opts: { file: File; options?: ParseOptions }) =>
|
||
kind === "837p"
|
||
? api.parse837(opts.file, opts.options)
|
||
: api.parse835(opts.file, opts.options),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["batches"] });
|
||
qc.invalidateQueries({ queryKey: ["claims"] });
|
||
qc.invalidateQueries({ queryKey: ["remittances"] });
|
||
qc.invalidateQueries({ queryKey: ["providers"] });
|
||
qc.invalidateQueries({ queryKey: ["activity"] });
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
- [x] **Step 7: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/hooks/
|
||
git commit -m "feat(frontend): add 6 query/mutation hooks (batches, claims, remits, providers, activity, parse)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 22: Refactor `Claims.tsx` to use `useClaims` + new UX patterns
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Claims.tsx`
|
||
|
||
- [x] **Step 1: Replace the file**
|
||
|
||
Replace `src/pages/Claims.tsx` with:
|
||
```tsx
|
||
import { useMemo, useRef, useState } from "react";
|
||
import { Search, X } from "lucide-react";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { ClaimStatusBadge } from "@/components/StatusBadge";
|
||
import { NewClaimDialog } from "@/components/NewClaimDialog";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { EmptyState } from "@/components/ui/empty-state";
|
||
import { ErrorState } from "@/components/ui/error-state";
|
||
import { FilterChips } from "@/components/ui/filter-chips";
|
||
import { Pagination } from "@/components/ui/pagination";
|
||
import { useClaims } from "@/hooks/useClaims";
|
||
import { useAppStore } from "@/store";
|
||
import { fmt } from "@/lib/format";
|
||
import type { ClaimStatus } from "@/types";
|
||
|
||
const ALL = "all" as const;
|
||
const statuses: (ClaimStatus | typeof ALL)[] = [
|
||
ALL,
|
||
"submitted",
|
||
"accepted",
|
||
"denied",
|
||
"paid",
|
||
"pending",
|
||
];
|
||
const PAGE_SIZE = 25;
|
||
|
||
export function Claims() {
|
||
const [status, setStatus] = useState<ClaimStatus | typeof ALL>(ALL);
|
||
const [npi, setNpi] = useState<string>(ALL);
|
||
const [query, setQuery] = useState("");
|
||
const [page, setPage] = useState(1);
|
||
const searchRef = useRef<HTMLInputElement>(null);
|
||
|
||
const providers = useAppStore((s) => s.providers);
|
||
const providerMap = useMemo(
|
||
() => Object.fromEntries(providers.map((p) => [p.npi, p])),
|
||
[providers]
|
||
);
|
||
|
||
const params = {
|
||
status: status === ALL ? undefined : status,
|
||
provider_npi: npi === ALL ? undefined : npi,
|
||
sort: "billedAmount",
|
||
order: "desc" as const,
|
||
limit: PAGE_SIZE,
|
||
offset: (page - 1) * PAGE_SIZE,
|
||
};
|
||
|
||
const { data, isLoading, isError, error, refetch } = useClaims(params);
|
||
const items = data?.items ?? [];
|
||
|
||
const activeChips = [
|
||
status !== ALL ? { key: "Status", label: status } : null,
|
||
npi !== ALL
|
||
? {
|
||
key: "NPI",
|
||
label: `${npi}${providerMap[npi] ? ` · ${providerMap[npi]!.name}` : ""}`,
|
||
}
|
||
: null,
|
||
].filter(Boolean) as { key: string; label: string }[];
|
||
|
||
const totals = useMemo(
|
||
() => ({
|
||
billed: items.reduce((s, c) => s + c.billedAmount, 0),
|
||
received: items.reduce((s, c) => s + c.receivedAmount, 0),
|
||
}),
|
||
[items]
|
||
);
|
||
|
||
// Local search filter on the current page (cheap; no extra fetch).
|
||
const visible = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return items;
|
||
return items.filter(
|
||
(c) =>
|
||
c.id.toLowerCase().includes(q) ||
|
||
c.patientName.toLowerCase().includes(q) ||
|
||
c.payerName.toLowerCase().includes(q) ||
|
||
c.cptCode.toLowerCase().includes(q)
|
||
);
|
||
}, [items, query]);
|
||
|
||
function clearFilter(key: string) {
|
||
if (key === "Status") setStatus(ALL);
|
||
if (key === "NPI") setNpi(ALL);
|
||
setPage(1);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-8 animate-fade-in">
|
||
<header className="flex items-end justify-between gap-6 flex-wrap">
|
||
<div>
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||
<span className="inline-block h-px w-6 bg-border" />
|
||
Claims
|
||
</div>
|
||
<h1 className="text-[22px] font-semibold tracking-tight">All claims</h1>
|
||
</div>
|
||
<NewClaimDialog />
|
||
</header>
|
||
|
||
{isError && error ? (
|
||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||
) : null}
|
||
|
||
<div className="surface rounded-xl p-4">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="relative flex-1 min-w-[240px]">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
ref={searchRef}
|
||
placeholder="Search by claim ID, patient, payer, CPT…"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
className="pl-9 pr-16"
|
||
/>
|
||
{query ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setQuery("")}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md"
|
||
aria-label="Clear search"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<Select
|
||
value={status}
|
||
onValueChange={(v) => {
|
||
setStatus(v as ClaimStatus | typeof ALL);
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-[150px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{statuses.map((s) => (
|
||
<SelectItem key={s} value={s}>
|
||
{s === ALL ? "All statuses" : s[0]!.toUpperCase() + s.slice(1)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={npi}
|
||
onValueChange={(v) => {
|
||
setNpi(v);
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-[240px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={ALL}>All providers</SelectItem>
|
||
{providers.map((p) => (
|
||
<SelectItem key={p.npi} value={p.npi}>
|
||
{p.npi} — {p.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<FilterChips
|
||
active={activeChips}
|
||
onRemove={clearFilter}
|
||
onClearAll={() => {
|
||
setStatus(ALL);
|
||
setNpi(ALL);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
|
||
<div className="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
|
||
<span>
|
||
<span className="num text-foreground font-medium">
|
||
{fmt.num(data?.total ?? 0)}
|
||
</span>{" "}
|
||
claims
|
||
</span>
|
||
<span>
|
||
Billed{" "}
|
||
<span className="num text-foreground font-medium">
|
||
{fmt.usd(totals.billed)}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
Received{" "}
|
||
<span className="num text-foreground font-medium">
|
||
{fmt.usd(totals.received)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="surface rounded-xl overflow-hidden">
|
||
{isLoading ? (
|
||
<div className="p-4 space-y-2">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} variant="row" />
|
||
))}
|
||
</div>
|
||
) : items.length === 0 ? (
|
||
<EmptyState
|
||
eyebrow="Claims · inbox idle"
|
||
message={
|
||
status !== ALL || npi !== ALL
|
||
? "No claims match the current filters."
|
||
: "Drop an 837P file on the Upload page to populate this list."
|
||
}
|
||
/>
|
||
) : (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Claim</TableHead>
|
||
<TableHead>Patient</TableHead>
|
||
<TableHead>Provider</TableHead>
|
||
<TableHead>Payer</TableHead>
|
||
<TableHead className="text-right">Billed</TableHead>
|
||
<TableHead className="text-right">Received</TableHead>
|
||
<TableHead>Status</TableHead>
|
||
<TableHead>Submitted</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{visible.map((c) => {
|
||
const provider = providerMap[c.providerNpi];
|
||
return (
|
||
<TableRow
|
||
key={c.id}
|
||
data-newer-than={c.parsedAt}
|
||
className="data-[newer-than]:animate-row-flash"
|
||
>
|
||
<TableCell>
|
||
<div className="display num text-[13px]">{c.id}</div>
|
||
<div className="text-[11px] text-muted-foreground num">
|
||
CPT {c.cptCode}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="font-medium">{c.patientName}</TableCell>
|
||
<TableCell>
|
||
<div className="text-sm">{provider?.name ?? "Unknown"}</div>
|
||
<div className="text-[11px] text-muted-foreground num">
|
||
{c.providerNpi}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{c.payerName}
|
||
</TableCell>
|
||
<TableCell className="text-right display num">
|
||
{fmt.usd(c.billedAmount)}
|
||
</TableCell>
|
||
<TableCell className="text-right display num text-muted-foreground">
|
||
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<ClaimStatusBadge status={c.status} />
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground text-[13px] num">
|
||
{fmt.dateShort(c.submissionDate)}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
<div className="px-4 pb-4">
|
||
<Pagination
|
||
page={page}
|
||
pageSize={PAGE_SIZE}
|
||
total={data?.total ?? 0}
|
||
onChange={setPage}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/pages/Claims.tsx
|
||
git commit -m "feat(frontend): refactor Claims page to useClaims + skeleton/empty/error/chips/pagination"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 23: Refactor `Remittances.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Remittances.tsx`
|
||
|
||
- [x] **Step 1: Replace the file**
|
||
|
||
Replace `src/pages/Remittances.tsx` with:
|
||
```tsx
|
||
import { useState } from "react";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { RemitStatusBadge } from "@/components/StatusBadge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { EmptyState } from "@/components/ui/empty-state";
|
||
import { ErrorState } from "@/components/ui/error-state";
|
||
import { Pagination } from "@/components/ui/pagination";
|
||
import { useRemittances } from "@/hooks/useRemittances";
|
||
import { fmt } from "@/lib/format";
|
||
|
||
const PAGE_SIZE = 25;
|
||
|
||
export function Remittances() {
|
||
const [page, setPage] = useState(1);
|
||
const { data, isLoading, isError, error, refetch } = useRemittances({
|
||
sort: "receivedDate",
|
||
order: "desc",
|
||
limit: PAGE_SIZE,
|
||
offset: (page - 1) * PAGE_SIZE,
|
||
});
|
||
const items = data?.items ?? [];
|
||
|
||
const total = items.reduce(
|
||
(acc, r) => ({
|
||
paid: acc.paid + r.paidAmount,
|
||
adjustments: acc.adjustments + r.adjustmentAmount,
|
||
}),
|
||
{ paid: 0, adjustments: 0 }
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-8 animate-fade-in">
|
||
<header>
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||
<span className="inline-block h-px w-6 bg-border" />
|
||
Remittances
|
||
</div>
|
||
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
|
||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||
Electronic remittance advice from payers, matched to submitted claims.
|
||
</p>
|
||
</header>
|
||
|
||
{isError && error ? (
|
||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||
) : null}
|
||
|
||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
|
||
<div className="surface rounded-xl p-5">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||
Remits
|
||
</div>
|
||
<div className="display text-[28px] leading-none mt-3">
|
||
{fmt.num(data?.total ?? 0)}
|
||
</div>
|
||
</div>
|
||
<div className="surface rounded-xl p-5">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||
Total paid
|
||
</div>
|
||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
|
||
</div>
|
||
<div className="surface rounded-xl p-5">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||
Adjustments
|
||
</div>
|
||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="surface rounded-xl overflow-hidden">
|
||
{isLoading ? (
|
||
<div className="p-4 space-y-2">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} variant="row" />
|
||
))}
|
||
</div>
|
||
) : items.length === 0 ? (
|
||
<EmptyState
|
||
eyebrow="Remittances · awaiting first 835"
|
||
message="Drop an 835 file on the Upload page to populate this list."
|
||
/>
|
||
) : (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Remit</TableHead>
|
||
<TableHead>Claim</TableHead>
|
||
<TableHead>Payer</TableHead>
|
||
<TableHead className="text-right">Paid</TableHead>
|
||
<TableHead className="text-right">Adjustment</TableHead>
|
||
<TableHead>Status</TableHead>
|
||
<TableHead>Received</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((r) => (
|
||
<TableRow
|
||
key={r.id}
|
||
data-newer-than={r.parsedAt}
|
||
className="data-[newer-than]:animate-row-flash"
|
||
>
|
||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||
<TableCell className="display num text-[13px] text-muted-foreground">
|
||
{r.claimId}
|
||
</TableCell>
|
||
<TableCell>{r.payerName}</TableCell>
|
||
<TableCell className="text-right display num">
|
||
{fmt.usdPrecise(r.paidAmount)}
|
||
</TableCell>
|
||
<TableCell className="text-right display num text-muted-foreground">
|
||
{fmt.usdPrecise(r.adjustmentAmount)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<RemitStatusBadge status={r.status} />
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground num text-[13px]">
|
||
{fmt.dateShort(r.receivedDate)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
<div className="px-4 pb-4">
|
||
<Pagination
|
||
page={page}
|
||
pageSize={PAGE_SIZE}
|
||
total={data?.total ?? 0}
|
||
onChange={setPage}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/pages/Remittances.tsx
|
||
git commit -m "feat(frontend): refactor Remittances page to useRemittances + UX patterns"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 24: Refactor `Providers.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Providers.tsx`
|
||
|
||
- [x] **Step 1: Replace the file**
|
||
|
||
Replace `src/pages/Providers.tsx` with:
|
||
```tsx
|
||
import { Building2, MapPin, Phone } from "lucide-react";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { EmptyState } from "@/components/ui/empty-state";
|
||
import { ErrorState } from "@/components/ui/error-state";
|
||
import { useProviders } from "@/hooks/useProviders";
|
||
import { fmt } from "@/lib/format";
|
||
|
||
export function Providers() {
|
||
const { data, isLoading, isError, error, refetch } = useProviders();
|
||
const items = data?.items ?? [];
|
||
|
||
return (
|
||
<div className="space-y-8 animate-fade-in">
|
||
<header>
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||
<span className="inline-block h-px w-6 bg-border" />
|
||
Providers
|
||
</div>
|
||
<h1 className="text-[22px] font-semibold tracking-tight">Provider directory</h1>
|
||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||
NPIs registered with the clearinghouse for 837P submission and 835
|
||
remittance retrieval.
|
||
</p>
|
||
</header>
|
||
|
||
{isError && error ? (
|
||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||
) : null}
|
||
|
||
{isLoading ? (
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<Skeleton key={i} variant="card" />
|
||
))}
|
||
</div>
|
||
) : items.length === 0 ? (
|
||
<div className="surface rounded-xl">
|
||
<EmptyState
|
||
eyebrow="Providers · directory empty"
|
||
message="Providers appear here as soon as an 837P file is parsed."
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((p) => (
|
||
<article
|
||
key={p.npi}
|
||
data-newer-than={undefined}
|
||
className="surface rounded-xl p-6 flex flex-col gap-4 hover:bg-muted/20 transition-colors"
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div className="h-10 w-10 rounded-lg bg-muted/60 flex items-center justify-center text-foreground">
|
||
<Building2 className="h-5 w-5" strokeWidth={1.5} />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="text-[15px] font-semibold tracking-tight">
|
||
{p.name}
|
||
</h3>
|
||
<p className="text-[11px] text-muted-foreground num mt-0.5">
|
||
NPI {p.npi} · TIN {p.taxId}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5 text-[13px] text-muted-foreground">
|
||
<div className="flex items-center gap-2">
|
||
<MapPin className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||
<span>
|
||
{p.address}, {p.city}, {p.state} {p.zip}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Phone className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||
<span className="num">{p.phone}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/40">
|
||
<div>
|
||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
Claims
|
||
</div>
|
||
<div className="display text-[20px] leading-none mt-1.5">
|
||
{fmt.num(p.claimCount)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
Outstanding AR
|
||
</div>
|
||
<div className="display text-[20px] leading-none mt-1.5">
|
||
{fmt.usd(p.outstandingAr)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/pages/Providers.tsx
|
||
git commit -m "feat(frontend): refactor Providers page to useProviders + UX patterns"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 25: Refactor `ActivityLog.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/ActivityLog.tsx`
|
||
|
||
- [x] **Step 1: Replace the file**
|
||
|
||
Replace `src/pages/ActivityLog.tsx` with:
|
||
```tsx
|
||
import { useActivity } from "@/hooks/useActivity";
|
||
import { ActivityFeed } from "@/components/ActivityFeed";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { EmptyState } from "@/components/ui/empty-state";
|
||
import { ErrorState } from "@/components/ui/error-state";
|
||
|
||
export function ActivityLog() {
|
||
const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 });
|
||
const items = data?.items ?? [];
|
||
|
||
return (
|
||
<div className="space-y-8 animate-fade-in">
|
||
<header>
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||
<span className="inline-block h-px w-6 bg-border" />
|
||
Activity
|
||
</div>
|
||
<h1 className="text-[22px] font-semibold tracking-tight">Activity log</h1>
|
||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||
Every claim submission, denial, payment, and provider event, in
|
||
reverse chronological order. Auto-refreshes every 30s.
|
||
</p>
|
||
</header>
|
||
|
||
{isError && error ? (
|
||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||
) : null}
|
||
|
||
<div className="surface rounded-xl p-6">
|
||
{isLoading ? (
|
||
<div className="space-y-2">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={i} variant="row" />
|
||
))}
|
||
</div>
|
||
) : items.length === 0 ? (
|
||
<EmptyState
|
||
eyebrow="Activity · log idle"
|
||
message="Activity will appear here after the first parse."
|
||
/>
|
||
) : (
|
||
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/pages/ActivityLog.tsx
|
||
git commit -m "feat(frontend): refactor ActivityLog page to useActivity + UX patterns"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 26: Refactor `Upload.tsx` to use `useParse` mutation
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/Upload.tsx`
|
||
|
||
- [x] **Step 1: Replace the file**
|
||
|
||
Replace `src/pages/Upload.tsx` with:
|
||
```tsx
|
||
import { useMemo, useRef, useState } from "react";
|
||
import {
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
ChevronRight,
|
||
FileText,
|
||
Loader2,
|
||
Upload as UploadIcon,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { api, type ParseProgress } from "@/lib/api";
|
||
import { fmt, toNum } from "@/lib/format";
|
||
import { useAppStore } from "@/store";
|
||
import { useParse } from "@/hooks/useParse";
|
||
import type {
|
||
ClaimOutput,
|
||
ClaimPayment,
|
||
ParsedBatch,
|
||
ParsedBatchKind,
|
||
ServiceLine,
|
||
ServicePayment,
|
||
} from "@/types";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type StreamedClaim837 = { kind: "837p"; data: ClaimOutput };
|
||
type StreamedClaim835 = { kind: "835"; data: ClaimPayment };
|
||
type StreamedClaim = StreamedClaim837 | StreamedClaim835;
|
||
|
||
interface StreamState {
|
||
items: StreamedClaim[];
|
||
expectedTotal: number | null;
|
||
passed: number;
|
||
failed: number;
|
||
}
|
||
|
||
const PAYERS_837 = [
|
||
{ value: "co_medicaid", label: "CO Medicaid" },
|
||
{ value: "generic_837p", label: "Generic 837P" },
|
||
];
|
||
const PAYERS_835 = [
|
||
{ value: "co_medicaid_835", label: "CO Medicaid" },
|
||
{ value: "generic_835", label: "Generic 835" },
|
||
];
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||
}
|
||
|
||
function ValidationDot({
|
||
passed,
|
||
hasWarnings,
|
||
}: { passed: boolean; hasWarnings?: boolean }) {
|
||
if (passed)
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--success))] font-medium">
|
||
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||
Passed
|
||
</span>
|
||
);
|
||
if (hasWarnings)
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--warning))] font-medium">
|
||
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||
Warnings
|
||
</span>
|
||
);
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 text-[11px] text-destructive font-medium">
|
||
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||
Failed
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function StatPill({ label, value, mono = true }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||
return (
|
||
<div className="flex flex-col gap-0.5">
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{label}</div>
|
||
<div className={cn("text-[13px] text-foreground", mono && "display num")}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
||
const [open, setOpen] = useState(false);
|
||
const passed = claim.validation.passed;
|
||
const hasWarnings = claim.validation.warnings.length > 0;
|
||
return (
|
||
<div className="surface rounded-lg overflow-hidden">
|
||
<button type="button" onClick={() => setOpen((v) => !v)}
|
||
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors" aria-expanded={open}>
|
||
<div className="flex items-center gap-3">
|
||
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0", open && "rotate-90")} strokeWidth={1.75} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2.5 flex-wrap">
|
||
<span className="display num text-[13px]">{claim.claim_id}</span>
|
||
<span className="text-[12px] text-muted-foreground truncate">{claim.subscriber.first_name} {claim.subscriber.last_name}</span>
|
||
<span className="text-[12px] text-muted-foreground">· {claim.payer.name}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
|
||
<span>NPI {claim.billing_provider.npi}</span>
|
||
<span>·</span>
|
||
<span>{claim.service_lines.length} line{claim.service_lines.length === 1 ? "" : "s"}</span>
|
||
<span>·</span>
|
||
<span>{claim.diagnoses.length} dx</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<div className="display num text-[14px]">{fmt.usdDecimal(claim.claim.total_charge)}</div>
|
||
<div className="mt-0.5"><ValidationDot passed={passed} hasWarnings={hasWarnings} /></div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
{open ? (
|
||
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<StatPill label="Member" value={claim.subscriber.member_id} />
|
||
<StatPill label="Place of service" value={claim.claim.place_of_service ?? "—"} />
|
||
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
|
||
<StatPill label="Prior auth" value={claim.claim.prior_auth ?? "—"} />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
||
const [open, setOpen] = useState(false);
|
||
const passed = claim.service_payments.length > 0;
|
||
return (
|
||
<div className="surface rounded-lg overflow-hidden">
|
||
<button type="button" onClick={() => setOpen((v) => !v)}
|
||
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors" aria-expanded={open}>
|
||
<div className="flex items-center gap-3">
|
||
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0", open && "rotate-90")} strokeWidth={1.75} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2.5 flex-wrap">
|
||
<span className="display num text-[13px]">{claim.payer_claim_control_number}</span>
|
||
<span className="text-[12px] text-muted-foreground">{claim.status_label ?? `Status ${claim.status_code}`}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
|
||
<span>CLP {claim.status_code}</span>
|
||
<span>·</span>
|
||
<span>{claim.service_payments.length} svc</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<div className="display num text-[14px] text-[hsl(var(--success))]">{fmt.usdDecimal(claim.total_paid)}</div>
|
||
<div className="text-[11px] text-muted-foreground num">of {fmt.usdDecimal(claim.total_charge)}</div>
|
||
<div className="mt-0.5"><ValidationDot passed={passed} /></div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Upload() {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [kind, setKind] = useState<ParsedBatchKind>("837p");
|
||
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
|
||
const [stream, setStream] = useState<StreamState>({
|
||
items: [], expectedTotal: null, passed: 0, failed: 0,
|
||
});
|
||
const addParsedBatch = useAppStore((s) => s.addParsedBatch);
|
||
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||
const parseMutation = useParse(kind);
|
||
|
||
const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835;
|
||
const totalSoFar = stream.items.length;
|
||
const progressPct = useMemo(() => {
|
||
if (!stream.expectedTotal || stream.expectedTotal <= 0) return 0;
|
||
return Math.min(100, Math.round((totalSoFar / stream.expectedTotal) * 100));
|
||
}, [stream.expectedTotal, totalSoFar]);
|
||
|
||
function pickFile(f: File | null) {
|
||
setFile(f);
|
||
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
|
||
}
|
||
|
||
async function onParse() {
|
||
if (!file) return;
|
||
if (!api.isConfigured) {
|
||
toast.error("Backend not configured. Set VITE_API_BASE_URL in .env.local to use the Upload page.");
|
||
return;
|
||
}
|
||
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
|
||
const items: StreamedClaim[] = [];
|
||
const onProgress: ParseProgress = (event) => {
|
||
if (event.type === "claim") {
|
||
items.push({ kind: "837p", data: event.data });
|
||
setStream((s) => ({ ...s, items: [...items] }));
|
||
} else if (event.type === "claim_payment") {
|
||
items.push({ kind: "835", data: event.data });
|
||
setStream((s) => ({ ...s, items: [...items] }));
|
||
} else if (event.type === "summary") {
|
||
setStream((s) => ({
|
||
...s,
|
||
expectedTotal: event.data.total_claims,
|
||
passed: event.data.passed,
|
||
failed: event.data.failed,
|
||
}));
|
||
}
|
||
};
|
||
try {
|
||
const summary = await parseMutation.mutateAsync({
|
||
file,
|
||
options: { payer, onProgress },
|
||
});
|
||
if (summary && typeof summary === "object" && "total_claims" in summary) {
|
||
const claimIds = items.map((c) => c.kind === "837p" ? c.data.claim_id : c.data.payer_claim_control_number).filter(Boolean);
|
||
const batch: ParsedBatch = {
|
||
id: `BATCH-${Date.now()}`,
|
||
kind,
|
||
inputFilename: file.name,
|
||
parsedAt: new Date().toISOString(),
|
||
claimCount: summary.total_claims,
|
||
passed: summary.passed,
|
||
failed: summary.failed,
|
||
claimIds,
|
||
summary,
|
||
};
|
||
addParsedBatch(batch);
|
||
toast.success(`Parsed ${summary.total_claims} ${kind === "837p" ? "claims" : "payments"} · ${summary.passed} passed · ${summary.failed} failed`, {
|
||
description: file.name,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "Failed to parse file");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-8 animate-fade-in">
|
||
<header>
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||
<span className="inline-block h-px w-6 bg-border" />
|
||
Upload · EDI parser
|
||
</div>
|
||
<h1 className="text-[22px] font-semibold tracking-tight">Parse an X12 file</h1>
|
||
<p className="text-muted-foreground mt-1.5 text-[14px] max-w-2xl">
|
||
Upload an 837P professional claim or 835 ERA remittance file. The parser streams claims back as they're produced so the UI updates in real time.
|
||
</p>
|
||
</header>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-[14px] flex items-center gap-2">
|
||
<UploadIcon className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
|
||
File
|
||
</CardTitle>
|
||
<CardDescription>
|
||
.txt (X12 837P or 835). The file is sent to the FastAPI backend configured by <code className="text-[12px]">VITE_API_BASE_URL</code>.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div className="grid gap-1.5">
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Kind</div>
|
||
<Select value={kind} onValueChange={(v) => {
|
||
const next = v as ParsedBatchKind;
|
||
setKind(next);
|
||
setPayer(next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value);
|
||
}}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="837p">837P (Professional claim)</SelectItem>
|
||
<SelectItem value="835">835 (ERA remittance)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Payer config</div>
|
||
<Select value={payer} onValueChange={setPayer}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{payerOptions.map((p) => (
|
||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) pickFile(f); }}
|
||
onClick={() => inputRef.current?.click()}
|
||
role="button" tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }}
|
||
className={cn(
|
||
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-8 cursor-pointer transition-colors",
|
||
"hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
)}
|
||
>
|
||
<UploadIcon className="h-5 w-5 text-muted-foreground" strokeWidth={1.5} />
|
||
{file ? (
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<FileText className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
|
||
<span className="font-medium">{file.name}</span>
|
||
<span className="text-muted-foreground num">· {formatBytes(file.size)}</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="text-sm font-medium">Drop a file here, or click to choose</div>
|
||
<div className="text-[12px] text-muted-foreground">.txt — the X12 837/835 file as exported from your clearinghouse</div>
|
||
</>
|
||
)}
|
||
<input ref={inputRef} type="file" accept=".txt" className="sr-only"
|
||
onChange={(e) => pickFile(e.target.files?.[0] ?? null)} />
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[12px] text-muted-foreground">
|
||
{api.isConfigured ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
|
||
Backend ready · {api.baseUrl}
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
|
||
<AlertTriangle className="h-3 w-3" strokeWidth={1.75} />
|
||
No backend configured — set VITE_API_BASE_URL to enable parsing
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{file ? (
|
||
<Button variant="ghost" onClick={() => pickFile(null)} disabled={parseMutation.isPending}>Clear</Button>
|
||
) : null}
|
||
<Button onClick={onParse} disabled={!file || parseMutation.isPending}>
|
||
{parseMutation.isPending ? (
|
||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Parsing…</>
|
||
) : (
|
||
<><UploadIcon className="h-3.5 w-3.5" /> Parse</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{(parseMutation.isPending || stream.items.length > 0) ? (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<CardTitle className="text-[14px] flex items-center gap-2">
|
||
{parseMutation.isPending ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" strokeWidth={1.75} />
|
||
) : (
|
||
<CheckCircle2 className="h-3.5 w-3.5 text-[hsl(var(--success))]" strokeWidth={1.75} />
|
||
)}
|
||
{kind === "837p" ? "Claims" : "Claim payments"}
|
||
</CardTitle>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
{totalSoFar} of {stream.expectedTotal ?? "?"} parsed
|
||
{stream.failed > 0 ? ` · ${stream.failed} failed validation` : ""}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="display num text-[16px]">{stream.expectedTotal ? `${progressPct}%` : "…"}</div>
|
||
<div className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">progress</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
|
||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${progressPct}%` }} />
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0">
|
||
<div className="max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
|
||
{stream.items.length === 0 && parseMutation.isPending ? (
|
||
<div className="text-center py-8 text-muted-foreground text-sm">Waiting for first claim…</div>
|
||
) : null}
|
||
{stream.items.map((item, i) =>
|
||
item.kind === "837p" ? (
|
||
<ClaimCard837 key={`837-${item.data.claim_id}-${i}`} claim={item.data} />
|
||
) : (
|
||
<ClaimCard835 key={`835-${item.data.payer_claim_control_number}-${i}`} claim={item.data} />
|
||
)
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
|
||
{parsedBatches.length > 0 ? (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-[14px]">Recent batches</CardTitle>
|
||
<p className="text-xs text-muted-foreground mt-1">Parsed files, newest first.</p>
|
||
</CardHeader>
|
||
<CardContent className="pt-0">
|
||
<ul className="divide-y divide-border/40">
|
||
{parsedBatches.map((b) => (
|
||
<li key={b.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||
<Badge variant={b.kind === "837p" ? "default" : "muted"}>{b.kind === "837p" ? "837P" : "835"}</Badge>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium truncate">{b.inputFilename}</div>
|
||
<div className="text-[11px] text-muted-foreground num">{fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<div className="display num text-[13px]">{b.passed} / {b.claimCount}</div>
|
||
<div className="text-[11px] text-muted-foreground">passed</div>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Verify build + commit**
|
||
|
||
```bash
|
||
npm run build
|
||
git add src/pages/Upload.tsx
|
||
git commit -m "feat(frontend): refactor Upload page to use useParse mutation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 27: Run frontend smoke (build + dev server check)
|
||
|
||
- [x] **Step 1: Build**
|
||
|
||
Run: `npm run build`
|
||
Expected: passes (no TS errors, no unused-import warnings).
|
||
|
||
- [x] **Step 2: Type-check**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: passes.
|
||
|
||
- [x] **Step 3: Commit any fixups (likely none)**
|
||
|
||
```bash
|
||
git status # should be clean
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3 — Docs
|
||
|
||
### Task 28: `docs/reference/837p.md`
|
||
|
||
**Files:**
|
||
- Create: `docs/reference/837p.md`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
```markdown
|
||
# 837P — Professional Claims (005010X222A1)
|
||
|
||
The 837P transaction set carries professional (outpatient) healthcare claims
|
||
from a billing provider to a payer. Cyclone parses the segments it needs to
|
||
produce a structured `ClaimOutput` and validates against CO Medicaid rules.
|
||
|
||
## File format
|
||
|
||
- Extension: `.txt`
|
||
- Encoding: ASCII (UTF-8 also accepted)
|
||
- Delimiters (declared in `ISA`): `*` element, `:` component, `~` segment, `^` repetition
|
||
|
||
## Envelope
|
||
|
||
| Segment | Purpose |
|
||
|---|---|
|
||
| `ISA` / `IEA` | Interchange envelope (sender ↔ receiver) |
|
||
| `GS` / `GE` | Functional group envelope |
|
||
| `ST` / `SE` | Transaction set envelope (837) |
|
||
|
||
## Loops
|
||
|
||
| Loop | Contents |
|
||
|---|---|
|
||
| 2000A | Billing provider hierarchy (NM1*85) |
|
||
| 2000B | Subscriber hierarchy (NM1*IL) |
|
||
| 2300 | Claim (CLM, HI, NM1, DTP, REF…) |
|
||
| 2400 | Service line (LX, SV1, DTP) |
|
||
|
||
## Segments Cyclone parses
|
||
|
||
- `NM1`, `N3`, `N4` — names and addresses
|
||
- `REF` — prior auth (`REF*G1`), provider taxonomy, etc.
|
||
- `CLM` — claim header; `CLM05` carries total charge + place-of-service + frequency
|
||
- `HI` — diagnoses (qualifier `ABK` = ICD-10 principal)
|
||
- `LX`, `SV1` — service line + procedure code
|
||
- `DTP` — service date
|
||
- `BHT` — beginning of hierarchical transaction
|
||
|
||
## Segments preserved but not modeled
|
||
|
||
All other segments are kept in `raw_segments` for audit but are not extracted
|
||
into the structured `ClaimOutput`. See `cyclone/parsers/parse_837.py` for the
|
||
full walker.
|
||
|
||
## CO Medicaid specifics
|
||
|
||
- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40`
|
||
- `CLM05` shape: `<total_charge>:<place_of_service>:<frequency_code>`
|
||
- Place-of-service: any valid CMS POS code
|
||
- Frequency code: must be one of `{1, 7, 8}` (1 = original, 7 = replacement, 8 = void)
|
||
- `REF*G1` carries prior-authorization number when applicable
|
||
- No 2010BA/2010CA patient loop — subscriber is the patient
|
||
- `CLM02` (provider signature on file) and `CLM07` (assignment of benefits) are typically `Y`
|
||
|
||
## Validation rules Cyclone enforces
|
||
|
||
| Rule | Severity | Description |
|
||
|---|---|---|
|
||
| `R001_frequency_allowed` | error | `CLM05-3` ∈ `{1, 7, 8}` |
|
||
| `R050_diagnosis_present` | error | At least one ICD-10 `HI*ABK` |
|
||
| `R100_provider_npi` | error | Billing provider NPI present and 10 digits |
|
||
| `R200_service_line_charge` | error | At least one `SV1` with non-zero charge |
|
||
| `R201_service_date` | warning | `DTP*472` present on every service line |
|
||
| `R300_subscriber_present` | error | `NM1*IL` (subscriber) present |
|
||
```
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/reference/837p.md
|
||
git commit -m "docs: add 837p reference note"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 29: `docs/reference/835.md`
|
||
|
||
**Files:**
|
||
- Create: `docs/reference/835.md`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
```markdown
|
||
# 835 — Electronic Remittance Advice (005010X221A1)
|
||
|
||
The 835 transaction set carries payment and remittance information from a
|
||
payer (or its clearinghouse) to a provider, in response to previously
|
||
submitted 837P claims. Cyclone parses it into a `ParseResult835` and surfaces
|
||
the per-claim payment breakdown.
|
||
|
||
## File format
|
||
|
||
Same as 837P — ASCII text with the same four delimiters declared in `ISA`.
|
||
|
||
## Envelope
|
||
|
||
Same as 837P (`ISA/IEA/GS/GE/ST/SE`).
|
||
|
||
## Loops
|
||
|
||
| Loop | Contents |
|
||
|---|---|
|
||
| 1000A | Payer (N1*PR) |
|
||
| 1000B | Payee (N1*PE) |
|
||
| 2100 | Claim payment (CLP) |
|
||
| 2110 | Service payment (SVC) |
|
||
|
||
## Header segments
|
||
|
||
- `BPR` — financial information (credit/debit flag, total paid amount, account details)
|
||
- `TRN` — reassociation trace number
|
||
- `DTM` — production date
|
||
|
||
## CLP — claim payment
|
||
|
||
`CLP01` = claim submitter's ID (patient control number); `CLP02` = status code.
|
||
|
||
| CLP02 | Meaning |
|
||
|---|---|
|
||
| 1 | Processed as Primary |
|
||
| 2 | Processed as Secondary |
|
||
| 3 | Processed as Tertiary |
|
||
| 4 | Denied |
|
||
| 19 | Processed as Primary, Forwarded |
|
||
| 20 | Processed as Secondary, Forwarded |
|
||
| 21 | Reversal of Previous Payment |
|
||
| 22 | Reversal of Previous Payment (Secondary) |
|
||
| 23 | Not Our Claim, Forwarded |
|
||
| 25 | Predetermination Pricing Only - No Payment |
|
||
|
||
## Critical balancing rules
|
||
|
||
Cyclone enforces (or flags) the following:
|
||
|
||
- `BPR02 == sum(CLP04)` — total paid equals sum of all claim paid amounts
|
||
- `CLP04 == sum(SVC03)` — per-claim paid equals sum of per-service paid
|
||
|
||
## CAS — claim/service adjustment
|
||
|
||
`CAS` segments carry reason codes (`CO` contractual obligation, `PR` patient
|
||
responsibility, `OA` other adjustments, `PI` payer-initiated reduction).
|
||
Cyclone preserves the raw `CAS` text in `raw_segments`; deep-parsing of
|
||
reason codes is sub-project 3.
|
||
|
||
## CO Medicaid specifics
|
||
|
||
- `BPR10` = `"81-1725341"` (TXIX / Medicaid) or `"84-0644739"` (BHA / behavioral health)
|
||
- `N1*PR` `N104` = `"7912900843"` — payer ID
|
||
- `N1*PR` `N102` = `"CO_TXIX"` or `"CO_BHA"` — payer name
|
||
- Allowed `CLP02` codes: `{1, 2, 3, 4, 19, 20, 21, 22, 23, 25}`
|
||
|
||
## Reconciliation (sub-project 2)
|
||
|
||
Matching 835 payouts back to 837P submissions by patient control number +
|
||
date of service is deferred to sub-project 2. Cyclone currently stores the
|
||
835 output as-is.
|
||
```
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/reference/835.md
|
||
git commit -m "docs: add 835 reference note"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 30: `docs/reference/x12naming.md`
|
||
|
||
**Files:**
|
||
- Create: `docs/reference/x12naming.md`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
```markdown
|
||
# X12 naming conventions
|
||
|
||
A short glossary of the names and indices used throughout Cyclone's parsers.
|
||
|
||
## Segments
|
||
|
||
Two- or three-letter codes that identify a row type. The codes are mnemonic:
|
||
`CLM` = claim, `NM1` = name, `BPR` = beginning segment for payment
|
||
order/remittance advice, `SVC` = service, `DTM`/`DTP` = date/time/period.
|
||
|
||
## Elements
|
||
|
||
Inside a segment, elements are separated by `*` and are 1-indexed. So
|
||
`CLM01` is the first element of the `CLM` segment — typically the patient
|
||
control number on 837P.
|
||
|
||
## Composite elements
|
||
|
||
A single element can carry sub-fields, separated by `:`. The sub-fields are
|
||
also 1-indexed, suffixed to the element index:
|
||
|
||
- `CLM05-1` = total charge
|
||
- `CLM05-2` = place of service
|
||
- `CLM05-3` = frequency code
|
||
|
||
In the Python code, this is `clm.claim.total_charge` (alias) and
|
||
`clm.claim.place_of_service` / `clm.claim.frequency_code`.
|
||
|
||
## Loops
|
||
|
||
Four-digit numeric IDs, hierarchical:
|
||
|
||
- 2000 (Billing/Subscriber hierarchy) contains
|
||
- 2300 (Claim) which contains
|
||
- 2400 (Service line)
|
||
|
||
In `cyclone.parsers`, the `parse_837` walker descends through these loops
|
||
explicitly.
|
||
|
||
## Common qualifiers Cyclone cares about
|
||
|
||
| Qualifier | Meaning | Where |
|
||
|---|---|---|
|
||
| `ABK` | ICD-10 principal diagnosis | `HI01-1` |
|
||
| `ABF` | ICD-10 diagnosis | `HI01-1` |
|
||
| `B` | Place of service | `CLM05-2` |
|
||
| `MC` | Medicaid (claim filing indicator) | 835 `CLP06` |
|
||
| `PR` | Payer | 835 `N1*PR` |
|
||
| `PE` | Payee | 835 `N1*PE` |
|
||
| `G1` | Prior authorization | 837P `REF01` |
|
||
| `TJ` | Federal taxpayer ID | 837P `REF01` (rendering provider TIN) |
|
||
| `1` | Original claim | 837P `CLM05-3` |
|
||
| `7` | Replacement claim | 837P `CLM05-3` |
|
||
| `8` | Void/cancel claim | 837P `CLM05-3` |
|
||
|
||
## The four delimiters
|
||
|
||
All four are declared in the `ISA` segment (positions 103–106 in the ISA
|
||
fixed-width header) and reused throughout the file. Cyclone's tokenizer
|
||
in `cyclone/parsers/segments.py` reads the ISA first, then splits the
|
||
rest of the file by those characters.
|
||
|
||
- `*` — element separator
|
||
- `:` — component (sub-element) separator
|
||
- `~` — segment terminator
|
||
- `^` — repetition separator
|
||
```
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/reference/x12naming.md
|
||
git commit -m "docs: add x12 naming reference note"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 31: `docs/reference/co-medicaid.md`
|
||
|
||
**Files:**
|
||
- Create: `docs/reference/co-medicaid.md`
|
||
|
||
- [x] **Step 1: Create the file**
|
||
|
||
```markdown
|
||
# Colorado Medicaid — payer specifics
|
||
|
||
Cyclone's CO Medicaid (`PayerConfig.co_medicaid()` and
|
||
`PayerConfig835.co_medicaid_835()`) factories encode the trading-partner
|
||
and code-set rules that the Colorado Department of Health Care Policy &
|
||
Financing (HCPF) requires.
|
||
|
||
## Trading Partner IDs (TPID)
|
||
|
||
| Role | 837P | 835 |
|
||
|---|---|---|
|
||
| Sender | `SKCO0` | (varies; usually the clearinghouse) |
|
||
| Receiver | `COHCPF` | (varies) |
|
||
|
||
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
|
||
the 837P file.
|
||
|
||
## Payer IDs
|
||
|
||
### 837P (claims)
|
||
|
||
- `NM1*PR N104 = "SKCO0"` (COHCPF)
|
||
|
||
### 835 (remittance)
|
||
|
||
- `BPR10 = "81-1725341"` — TXIX (Medicaid) account
|
||
- `BPR10 = "84-0644739"` — BHA (behavioral health) account
|
||
- `N1*PR N104 = "7912900843"` — payer ID
|
||
- `N1*PR N102` is one of `"CO_TXIX"` or `"CO_BHA"`
|
||
|
||
## Allowed codes
|
||
|
||
### 837P `CLM05-3` (frequency code)
|
||
|
||
| Code | Meaning |
|
||
|---|---|
|
||
| 1 | Original claim |
|
||
| 7 | Replacement claim |
|
||
| 8 | Void / cancel |
|
||
|
||
Cyclone raises `R001_frequency_allowed` if any other value appears.
|
||
|
||
### 835 `CLP02` (claim status code)
|
||
|
||
Allowed: `{1, 2, 3, 4, 19, 20, 21, 22, 23, 25}`. See the 835 reference
|
||
note for the meaning of each.
|
||
|
||
### Place of service (POS)
|
||
|
||
All 89 CMS POS codes are accepted. The canonical list lives in
|
||
`cyclone/parsers/payer.py` as `CMS_PLACE_OF_SERVICE_CODES` and is the
|
||
source of truth for validation and any UI dropdowns.
|
||
|
||
## Validation rules Cyclone enforces
|
||
|
||
See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the
|
||
`cyclone.parsers.validator` / `cyclone.parsers.validator_835` modules.
|
||
|
||
## Adding a new payer
|
||
|
||
The pattern is:
|
||
|
||
1. Add a `PayerConfig` (837P) and `PayerConfig835` (835) factory in
|
||
`cyclone/parsers/payer.py` — usually thin wrappers that override only
|
||
the differing fields (allowed codes, payer IDs).
|
||
2. Register the factory in the `PAYER_FACTORIES` / `PAYER_FACTORIES_835`
|
||
dicts in `cyclone/api.py` and `cyclone/cli.py`.
|
||
3. Add a smoke fixture under `backend/tests/fixtures/` (a real or
|
||
synthetic file matching the new payer's rules) and a parser test that
|
||
loads it.
|
||
4. Update this note + the 837p/835 notes with the new payer's specifics.
|
||
```
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/reference/co-medicaid.md
|
||
git commit -m "docs: add CO Medicaid reference note"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 32: Root `README.md` rewrite
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
- [x] **Step 1: Replace the file**
|
||
|
||
Replace `README.md` (root) with:
|
||
```markdown
|
||
# Cyclone
|
||
|
||
A self-hosted EDI claims management suite for a single billing office.
|
||
Parses 837P professional claims and 835 ERA remittances (X12 005010X222A1
|
||
and 005010X221A1), with a local-only FastAPI backend and a React UI for
|
||
browsing, filtering, and inspecting the parsed data.
|
||
|
||
Local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure.
|
||
Built for one operator, one machine, one trading partner (Colorado
|
||
Medicaid, currently).
|
||
|
||
## Install
|
||
|
||
```bash
|
||
# Backend (Python 3.11+)
|
||
cd backend
|
||
python -m venv .venv
|
||
.venv/bin/pip install -e '.[dev]'
|
||
|
||
# Frontend (Node 20+)
|
||
cd ..
|
||
npm install
|
||
```
|
||
|
||
## Dev
|
||
|
||
Two terminals:
|
||
|
||
```bash
|
||
# Terminal 1 — backend
|
||
cd backend
|
||
.venv/bin/python -m cyclone serve
|
||
# (defaults to 127.0.0.1:8000; override with CYCLONE_PORT=...; reload with CYCLONE_RELOAD=1)
|
||
|
||
# Terminal 2 — frontend
|
||
npm run dev
|
||
# (Vite on http://localhost:5173)
|
||
```
|
||
|
||
Then open `http://localhost:5173`. Drop an `.txt` 837P or 835 file on the
|
||
Upload page; navigate to Claims, Remittances, Providers, or Activity to
|
||
see the parsed data.
|
||
|
||
The frontend reads its backend URL from `VITE_API_BASE_URL` (default
|
||
empty). Create a `.env.local` at the repo root with:
|
||
|
||
```
|
||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||
```
|
||
|
||
Without that, the UI falls back to its in-memory sample store via the
|
||
existing `data` adapter (parses are disabled).
|
||
|
||
## Test
|
||
|
||
```bash
|
||
# Backend
|
||
cd backend && .venv/bin/pytest
|
||
|
||
# Frontend type-check + build
|
||
npm run typecheck
|
||
npm run build
|
||
|
||
# Frontend unit tests (when added)
|
||
npm test
|
||
```
|
||
|
||
## Project layout
|
||
|
||
```
|
||
.
|
||
├── backend/
|
||
│ ├── src/cyclone/
|
||
│ │ ├── api.py # FastAPI app, GET + parse routes
|
||
│ │ ├── store.py # InMemoryStore, mappers
|
||
│ │ ├── __main__.py # `python -m cyclone serve`
|
||
│ │ ├── cli.py # click CLI
|
||
│ │ └── parsers/ # X12 tokenizer, models, validator, writers
|
||
│ └── tests/
|
||
│ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt
|
||
│ ├── test_api.py # parse-837/835 round-trip
|
||
│ ├── test_api_gets.py # 6 GET endpoints
|
||
│ ├── test_store.py # store + mappers + iterators
|
||
│ ├── test_api_streaming.py
|
||
│ └── test_api_parse_persists.py
|
||
├── src/ # React + Vite + TypeScript UI
|
||
│ ├── components/
|
||
│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload
|
||
│ ├── hooks/ # react-query wrappers
|
||
│ ├── lib/ # api.ts, format.ts, utils.ts
|
||
│ ├── store/ # zustand sample-data store
|
||
│ └── types/ # shared TS types
|
||
├── docs/
|
||
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes
|
||
│ └── prodfiles/ # sample EDI files (anonymized)
|
||
└── package.json
|
||
```
|
||
|
||
## Roadmap
|
||
|
||
This is **sub-project 1 of 4**. The other three are not in this build:
|
||
|
||
- **Sub-project 2 — DB + reconciliation.** Replace the in-memory store
|
||
with SQLite/Postgres; add 837P ↔ 835 reconciliation matching payouts to
|
||
original claims.
|
||
- **Sub-project 3 — More 837P/835 features.** Additional 837P validation
|
||
rules (REF*G1 enforcement, BHT06), 835 CAS deep-parsing, 999 ACK, and
|
||
270/271 eligibility.
|
||
- **Sub-project 4 — Frontend features.** Per-claim detail drawer, batch
|
||
diff view, real-time streaming of NDJSON GET responses, advanced
|
||
filters (date range, multi-status, saved filter sets), keyboard-driven
|
||
navigation.
|
||
|
||
## License
|
||
|
||
No license file yet; this is internal-use software. Add a `LICENSE` file
|
||
when one is decided.
|
||
```
|
||
|
||
- [x] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs: rewrite root README for sub-project 1"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — End-to-end smoke
|
||
|
||
### Task 33: End-to-end smoke test (manual + documented)
|
||
|
||
- [x] **Step 1: Start the backend**
|
||
|
||
Terminal 1: `cd backend && .venv/bin/python -m cyclone serve`
|
||
Expected: log line "Uvicorn running on http://127.0.0.1:8000".
|
||
|
||
- [x] **Step 2: Confirm loopback-only**
|
||
|
||
Terminal 2: `curl -s http://127.0.0.1:8000/api/health | jq .` → `{"status": "ok", "version": "0.1.0"}`.
|
||
Then: `curl -s http://0.0.0.0:8000/api/health` → connection refused.
|
||
|
||
- [x] **Step 3: Start the frontend**
|
||
|
||
Terminal 3: `npm run dev`
|
||
Expected: "Local: http://localhost:5173".
|
||
|
||
- [x] **Step 4: Drop a fixture file and verify live data flow**
|
||
|
||
1. Open `http://localhost:5173/upload` in a browser.
|
||
2. Drop `backend/tests/fixtures/co_medicaid_837p.txt` on the upload zone.
|
||
3. Click **Parse**. Expect: 2 cards stream in; success toast appears.
|
||
4. Navigate to `/claims`. Expect: 2 rows, both `submitted`.
|
||
5. Navigate to `/remittances`. Expect: empty state "Remittances · awaiting first 835".
|
||
6. Navigate to `/providers`. Expect: 1 provider card, 2 claims.
|
||
7. Navigate to `/activity`. Expect: 2 events, both `claim_submitted`.
|
||
8. Apply `Status: submitted` filter on `/claims`. Expect: the same 2 rows; the `FilterChips` row shows "Status: submitted ×".
|
||
9. Watch the top of `<main>` while navigating back to `/upload` and re-parsing — the 1px scan-line indicator should appear briefly.
|
||
|
||
- [x] **Step 5: Verify NDJSON streaming**
|
||
|
||
Terminal 2: `curl -s -N -H 'Accept: application/x-ndjson' http://127.0.0.1:8000/api/claims | head -5`
|
||
Expected: 2 lines of `{"type":"item",…}` + 1 line of `{"type":"summary",…}`.
|
||
|
||
- [x] **Step 6: Stop the backend and verify the UI reverts to the fallback**
|
||
|
||
Kill terminal 1 (`Ctrl+C`). Refresh the browser. Expect: the pages still
|
||
render (using the zustand fallback via the existing `data` adapter), but
|
||
the upload page shows "No backend configured".
|
||
|
||
- [x] **Step 7: Final commit (likely none)**
|
||
|
||
```bash
|
||
git status # should be clean
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review (against the spec)
|
||
|
||
**Spec coverage** — every section/requirement in the design spec maps to at least one task:
|
||
|
||
| Spec section | Covered by |
|
||
|---|---|
|
||
| §2 Goals 1: in-memory store | T1, T2, T3 |
|
||
| §2 Goals 2: GET endpoints with filter/sort/pagination, NDJSON | T5, T6, T7, T8 |
|
||
| §2 Goals 3: react-query wiring | T11, T12, T20, T21, T22, T23, T24, T25, T26 |
|
||
| §2 Goals 4: 4 reference notes + root README | T28, T29, T30, T31, T32 |
|
||
| §2 Goals 5: bind 127.0.0.1:8000 | T9 |
|
||
| §3 Non-goals | Not implemented (intentional) |
|
||
| §6.1 `cyclone.store` module | T1, T2, T3 |
|
||
| §6.2 6 GET routes | T5, T6, T7 |
|
||
| §6.2 NDJSON streaming | T8 |
|
||
| §6.2 Status mapping (837P submitted/pending/denied; 835 received/reconciled) | T2 |
|
||
| §6.3 parse-837/parse-835 → store.add() | T4 |
|
||
| §6.4 uvicorn bind | T9 |
|
||
| §7.1 @tanstack/react-query dep | T11 |
|
||
| §7.2 QueryClientProvider | T12 |
|
||
| §7.3 `lib/api.ts` GET methods | T20 |
|
||
| §7.4 hooks | T21 |
|
||
| §7.5 page refactors | T22, T23, T24, T25, T26 |
|
||
| §7.6 Skeleton primitive | T14 |
|
||
| §7.7.1–7.7.8 live-data UX | T13 (keyframes), T15 (Empty), T16 (Error), T17 (Chips), T18 (Pagination), T19 (refetch indicator), T22–T26 (row flash) |
|
||
| §8 docs (4 notes + README) | T28–T32 |
|
||
| §10 testing (~23 backend, 3 frontend) | T1–T8 (backend), T20 (frontend) |
|
||
| §11 migration/rollout | T9 (uvicorn bind) + T4 (store populated on parse) |
|
||
| §13 acceptance checklist | T33 (manual) |
|
||
| §13.1 visual / aesthetic checks | T13, T14, T15, T16, T17, T18, T19, T22–T26 |
|
||
|
||
**No placeholders.** No "TBD", "TODO", "implement later", or vague "add
|
||
appropriate error handling" — every step has concrete code.
|
||
|
||
**Type consistency.** 837P statuses `draft|submitted|accepted|denied|paid|pending`
|
||
and 835 statuses `received|posted|reconciled` appear consistently across
|
||
T2, T21, T22, T23, T25, T26 and the 7.7.7 spec section. Color tokens
|
||
(`success|accent|warning|destructive|muted`) match the existing Tailwind
|
||
config. Keyframe names `shimmer|scan|row-flash` are defined once in T13
|
||
and referenced consistently in T14, T19, T22, T23, T25.
|
||
|
||
**File path accuracy.** All paths verified against the current repo:
|
||
`backend/src/cyclone/store.py` (new), `backend/src/cyclone/api.py` (modify),
|
||
`backend/src/cyclone/__main__.py` (modify), `src/main.tsx` (modify),
|
||
`src/components/Layout.tsx` (modify), `src/lib/api.ts` (modify),
|
||
`src/pages/{Claims,Remittances,Providers,ActivityLog,Upload}.tsx` (modify),
|
||
`docs/reference/{837p,835,x12naming,co-medicaid}.md` (new), `README.md` (modify).
|
||
|
||
---
|
||
|
||
## Execution
|
||
|
||
This plan is ready for the **subagent-driven-development** skill. Each
|
||
backend task is small enough to fit in a single subagent (≤ 30 minutes);
|
||
the frontend tasks are similarly scoped. The docs tasks (T28–T32) are
|
||
mechanical markdown and can be dispatched in parallel from a single
|
||
subagent.
|
||
|
||
Approximate effort (subagent wall-clock):
|
||
- Phase 1 (backend): 6 subagents, ~90 min total with parallelism
|
||
- Phase 2 (frontend): 8 subagents, ~120 min total
|
||
- Phase 3 (docs): 1 subagent, ~20 min
|
||
- Phase 4 (smoke): manual, ~10 min
|
||
|
||
Total: ~4 hours of subagent work + review.
|
||
|
||
**Review checkpoints** (built into the subagent-driven-development skill):
|
||
- After every 3 tasks, the orchestrator runs `requesting-code-review` on
|
||
the cumulative diff.
|
||
- After Phase 1 completes, run the full backend test suite as a gate
|
||
before starting Phase 2.
|
||
- After Phase 2 completes, run `npm run build` as a gate before starting
|
||
Phase 3.
|
||
- After Phase 3, run the T33 end-to-end smoke as the final acceptance.
|