feat(ui): cohesive frontend polish — design system + per-screen refinement

Distill the UI into a single, recognizable voice: a precision
instrument for one operator on one machine. Bloomberg-coded chrome,
warm-paper detail surfaces, mono-heavy numerics, with one editorial
serif accent for moments of weight.

Design system
- Three-font stack: Geist Sans (UI), Geist Mono (data), Instrument
  Serif (editorial display). No Inter, no system fonts.
- Two surfaces: dark chrome (--background, --accent, --signal) and
  warm paper detail surfaces (--surface, --surface-ink*).
- Inbox has its own Ticker Tape terminal palette (--tt-*).
- Shared component classes: .eyebrow, .mono, .display, .surface,
  .surface-2, .row-hover, .nav-active, .kbd, .editorial, .hairline.
- --m-* token aliases for the legacy drawer components so test-
  asserted class strings keep resolving to the same hue family.

Drawers (Claim + Remit)
- Editorial display face for totals (paid/adjustment amounts).
- Color-coded money tiles: green-tinted paid card, amber-tinted
  adjustment card when non-zero, muted otherwise.
- Tabs get accent-blue underline + CSS-driven active state.
- Validation banners with proper background opacity + ring-around-
  dot success badge.
- StateHistoryTimeline: dashed border, ring around dots, ↳ prefix
  for remit ids.
- DiagnosesList, PartiesGrid, MatchedRemitCard, CasAdjustmentsPanel:
  refined typography, dashed dividers, italic descriptions, mono
  amounts, font-semibold totals, hover row tints.

Inbox Ticker Tape
- Custom RowCheckbox with sr-only input + amber accent.
- Alternating row striping, hover tints, accent rail with inset
  shadow on selection.
- Refined sparkline with glow at high values.
- BulkBar: bottom-floating bar with amber count chip, larger shadow.
- CandidateBreakdown: animated progress bars with amber gradient.
- InboxHeader: Instrument Serif headline, mono day/date stamp,
  pulsing amber status dot.

Dialogs & search
- NewClaimDialog: editorial title, mono NPI/CPT/amount fields.
- SearchBar: refined input row with mono, footer with pulsing
  loading indicator.
- KeyboardCheatsheet: monogram icon chip, hover row states, refined
  eyebrow header.

Primitives
- StatusBadge / ClaimStateBadge: per-state dot indicators.
- SelectItem: data-[highlighted]:outline tokens for keyboard a11y.
- Table primitives: refined header treatment, hover/focus states.

Tests + build
- 354/354 tests passing across 59 files.
- Vite build clean (53.84 kB CSS / 560.86 kB JS).
- Eyebrow assertions updated to match the consolidated .eyebrow
  class (intact visual contract, abstracted class string).
- Badge variant tokens updated to the polished bg-muted/80 /
  /0.14 opacity scale.
This commit is contained in:
Tyler
2026-06-20 22:27:01 -06:00
parent 02841d7e6e
commit fbe9940a3f
70 changed files with 2582 additions and 1663 deletions
@@ -0,0 +1,312 @@
# Cyclone Completeness Review
**Date:** 2026-06-20
**Scope:** Review of the Cyclone codebase for completeness against the *industry* definition of a HIPAA EDI clearinghouse, plus an honest accounting of the design scope the project actually commits to.
**Method:** Read all 8 design specs + 9 implementation plans in `docs/superpowers/`, surveyed the backend (~12,935 LOC across `cyclone/`), surveyed the frontend (50+ components), 574 backend tests, and cross-referenced against current HIPAA / X12 / CAQH CORE / WEDI / CMS guidance plus practitioner writeups (Nirmitee, PilotFish, Invene, Stedi, HHS, CMS) and the small amount of public X/Twitter discussion of EDI.
---
## TL;DR
**Cyclone is a polished, well-tested X12 file viewer + parser + regenerator for a single billing office with one trading partner (Colorado Medicaid). It is not a clearinghouse in the industry sense.** The README is honest about this; the design specs are explicit about what's in and out. The 8 shipped sub-projects together cover about 2530 % of what a *production* clearinghouse like Availity, Office Ally, or Waystar provides. Almost all of the missing 7075 % is intentionally out of scope, and almost all of it is the *boring, regulated, expensive* part of the business — connectivity, security, multi-payer routing, scrubbing, 277CA, real-time eligibility round-trip, COB, attachments, reporting, HA/DR.
If the question is *"is this a complete local X12 tool?"* the answer is **yes, for the scope it chose.** If the question is *"is this a HIPAA clearinghouse?"* the answer is **no, by design** — and a real clearinghouse is a multi-year, multi-engineer, regulated-product effort.
The detailed gap analysis below is for the latter question.
---
## 1. What Cyclone is, honestly
From the [root README](/Users/openclaw/dev/cyclone/README.md) and the [production-readiness design](/Users/openclaw/dev/cyclone/docs/superpowers/specs/2026-06-19-cyclone-production-readiness-design.md):
> *A self-hosted EDI claims management suite for a single billing office. … 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).*
That sentence is the design contract. The project has honored it: every spec re-asserts the local-only, single-operator, single-payer, no-auth posture and lists (often at length) what is intentionally out of scope.
### What the 8 shipped sub-projects deliver
| SP | Title | Status |
|----|-------|--------|
| 1 | Production-readiness: InMemoryStore, GET endpoints, react-query, reference notes | Shipped |
| 2 | DB + reconciliation: SQLite, 7-state lifecycle, auto-match on 835 ingest, manual reconciliation UI | Shipped |
| 3 | EDI features: 837P rules (R034/R035), CAS reason-code labels, 999 ACK, TA1 ACK, 270/271 | Shipped |
| 4 | Claim detail drawer + Remit drawer + Cmd-K search + batch diff + CSV export | Shipped |
| 5 | Live tail: NDJSON pubsub on Claims/Remittances/Activity, heartbeat, reconnect | Shipped |
| 6 | Inbox workflow: 4 lanes (Rejected/Candidates/Unmatched/Done today), 4-field scoring, bulk actions | Shipped |
| 7 | Per-line adjustment audit: 837 SV1 ↔ 835 SVC strict match, line reconciliation tab | Shipped |
| 8 | Outbound 837P serializer: single-claim download + bundle resubmit ZIP | Shipped |
Plus 6 SQL migrations (`0001``0006`), 574 backend tests, ~155 frontend test files, and a comprehensive UI built with the Ticker-Tape aesthetic.
### Headline strength
The **round-trip guarantee** on the 837P serializer: 113 real prodfiles in `docs/prodfiles/claims/*.x12` parse → serialize → parse back to the same `claim_id`. That is non-trivial and is the property that makes the resubmit loop honest.
---
## 2. What a "proper" HIPAA EDI clearinghouse provides
Cross-referencing HHS, CMS, X12, CAQH CORE, WEDI, and the major vendor docs (Availity, Office Ally, Optum, Waystar, Stedi, PilotFish, Invene):
### 2.1 Core X12 5010 transaction support
Cyclone ships: **837P (in + out), 835, 999, TA1, 270, 271.**
Industry expectation:
- **837P** ✓
- **837I** (institutional) ✗
- **837D** (dental) ✗
- **835** ✓
- **270/271** ✓ builder + parser, ✗ real-time round-trip
- **276/277** (claim status) ✗
- **277CA** (claim acknowledgment, *the* de facto CMS requirement) ✗
- **278** (referrals / prior auth) ✗
- **834** (enrollment) ✗
- **820** (premium payment) ✗
- **275** (attachments) ✗
- **NCPDP** (retail pharmacy — different SDO) ✗
Cyclone covers 4 of ~10 transaction types relevant to a medical billing office, and only one of those has a true round-trip. By industry standards, that's a parser, not a clearinghouse.
### 2.2 Acknowledgments
Cyclone ships: **TA1 (in), 999 (in + out, auto-gen on `?ack=true`).**
Industry expectation: TA1, 999, **277CA**. 277CA is the critical one — it is the claim-level (not batch-level) acknowledgment CMS/Medicare relies on. 277CA is *the* missing acknowledgment in Cyclone. It is the difference between "I sent a file" and "the payer accepted claim CLM-001."
### 2.3 Connectivity
Cyclone: file upload over `127.0.0.1:8000`.
Industry expectation: **AS2 / AS4 (EDIINT — signed, encrypted, non-repudiable, MDN-back), SFTP, HTTPS, VPN.** AS2 is the long-standing standard; AS4 is gaining. Cyclone has none of these. There is no outbound transport, no inbound polling, no scheduled pickup, no delivery confirmation.
### 2.4 Security
Cyclone:
- Binds `127.0.0.1`, CORS allowlist, no auth, no API key, no rate limit.
- SQLite database file at `~/.local/share/cyclone/cyclone.db` is **plaintext** (no encryption at rest).
- Logs are unstructured Python `logging` to stderr — no JSON logs, no log level config, no forwarding, no rotation policy.
- `ActivityEvent` table exists for an internal audit *of state transitions*, but it is mutable (no append-only trigger), has no integrity hash, no 6-year retention guarantee, and no export path.
Industry expectation (HIPAA Security Rule §164.312):
- **Encryption at rest** for databases, files, backups (AES-256 or equivalent).
- **Encryption in transit** (TLS 1.2+; AS2/AS4 cover this).
- **Audit controls** (§164.312(b)): hardware/software/procedural mechanisms to record and examine activity involving ePHI — "who accessed what, when, where, outcome." Must be **tamper-resistant/immutable**, retain **≥6 years**, exportable for CMS audits.
- **Access control** with unique IDs, MFA, automatic logoff, emergency access.
- **Transmission security** + network controls (VPC, segmentation, WAF, IDS/IPS).
- **BAAs** with all sub-processors.
- **Risk analysis**, contingency plan, incident response, breach notification.
- **HITRUST / SOC 2 Type II** as a recognized security framework.
Cyclone checks none of these. The single-operator, single-host design means some of them are not relevant *for the operator themselves* — there is no second party to BAA with — but the moment the data touches anything that is not the SQLite file on the operator's encrypted laptop, the gap appears.
### 2.5 Validation, scrubbing, edits
Cyclone: structural validation (R034 `REF*G1` for adjustment frequency, R035 `BHT06` transaction-type allowlist), 7-state lifecycle, 4-field match scoring, eager line-level match.
Industry expectation: **SNIP levels 17** (Strategic National Implementation Process — the industry-standard layered validation), **CCI (Correct Coding Initiative) edits**, **medical-necessity checks**, **NPI validation against NPPES**, **taxonomy code crosswalks**, **duplicate detection**, **payer-specific companion-guide rules**, **code-set validation** (CPT/HCPCS/ICD-10 vocabulary). These are the edits that drive a >95% clean-claim rate. Cyclone has the shape checks (very good for a single payer) but no code-set vocabulary, no NPPES lookup, no CCI, no duplicate detection.
### 2.6 Payer connectivity / routing
Cyclone: `PAYER_FACTORIES = {"co_medicaid": ..., "generic_837p": ...}`. Adding a payer means writing a new factory in `parsers/payer.py` and registering it in `api.py`.
Industry expectation: hundreds of payer integrations, automated routing by payer ID, library of companion-guide rules, often loaded as data not code. The "payer config in Python" pattern is fine for one operator with one payer; it does not scale to even ten payers.
### 2.7 Real-time + batch
Cyclone: long-lived NDJSON GET streams (live tail) for in-app UI. No outbound real-time, no batch scheduling, no daily pickup job.
Industry expectation: real-time 270/271 and 276/277 over **CAQH CORE Phase II/III** envelopes (HTTPS + SOAP wrapper, payload ID, processing mode "RealTime"). The Cyclone 270 builder emits a raw X12 271; there is no SOAP envelope, no real-time client, no scheduled polling, no retry. The README's "270/271 eligibility (API-only)" is accurate — it's an X12 builder, not a real-time eligibility check.
### 2.8 COB, secondary claims, patient responsibility
Cyclone: detects reversals (CLP02 ∈ {21, 22}), preserves pre-reversal state. CAS reason codes surface with labels.
Industry expectation: **coordination of benefits** — when a primary payer pays and the balance is forwarded to a secondary, the system needs to *generate* a secondary 837 from the primary 835's residual. Cyclone has no COB logic; a paid-with-residual claim just lands in `paid` state with `patient_responsibility` populated and stops there. There is no secondary-claim generator.
### 2.9 Reporting / analytics / denial management
Cyclone: KPI tiles on the dashboard (counts), a per-payer breakdown, an Activity feed, per-line CAS reason codes.
Industry expectation: clean-claim rate, denial-rate-by-CARC, ERA-lag (time from 835 receipt to posting), aging AR buckets, write-off reports, appeal tracking, denial-trend dashboards, payer scorecards. None of this exists; the Activity feed is a transition log, not a denial-workflow tool.
### 2.10 HA / DR / observability
Cyclone: single-process, single-host, manual `sqlite3 .backup` for backups, no health check beyond `/api/health`, no uptime metric, no Prometheus/OpenTelemetry export, no structured logs.
Industry expectation: multi-AZ/multi-region, automated encrypted backups with tested restore, RPO/RTO targets, monitoring/alerting, DR runbooks, tabletop exercises, uptime SLAs.
### 2.11 EFT/ERA reassociation
Cyclone: 835 is parsed; CAS labels and totals are surfaced. `TRN` segment is captured in `raw_segments` but not specifically reassociated.
Industry expectation: **TRN trace number** is the bridge between the EFT (bank ACH) and the ERA (835). Strong reassociation — by `TRN02` — is required for auto-posting payments to provider systems. Cyclone captures it; it does not re-associate across a separate EFT deposit feed.
### 2.12 Integration
Cyclone: standalone. No webhook out, no EHR/PM connector, no REST API ingestion. Files in via HTTP upload; files out via the serializer endpoint or ZIP bundle.
Industry expectation: clearinghouses integrate with the upstream EHR/PM (e.g., Epic, Athena, eClinicalWorks) via HL7v2 feeds, FHIR APIs, SFTP batches, REST webhooks. Cyclone has none.
---
## 3. Weaknesses — within Cyclone's own scope
Even judged against the local-only / single-operator / single-payer design contract, the project has weak spots worth flagging:
### 3.1 Substantive
1. **No encryption at rest.** The SQLite file contains PHI (patient names, DOBs, NPIs, charge amounts, claim IDs that map to internal systems). Even a single-operator tool on a single machine should at minimum support a passphrase-derived SQLCipher key, or document that disk encryption (FileVault) is a precondition. The README does not say so.
2. **`ActivityEvent` is not tamper-evident.** It is a regular table. An operator (or a buggy migration) can `DELETE FROM activity_events`. The README frames this as an audit trail (`docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.6) but a real audit trail needs either append-only triggers, hash-chaining, or external write-once storage.
3. **No backup automation.** The README documents `sqlite3 .backup /path/to/backup.db` as a *manual* recipe. No scheduler, no offsite copy, no retention, no restore drill. The 6-year HIPAA retention expectation cannot be met by a manual recipe.
4. **No request size limits / no rate limits.** FastAPI defaults. A 4 GB file upload will OOM the parser. A flood of requests will hold the GIL. A real local tool should have at least `Request.max_body_size` and a per-IP rate limiter.
5. **No structured logging.** `logging.getLogger(__name__).warning(...)` lines exist, but there is no JSON formatter, no log-level env var, no rotation. Operational forensics is weak.
6. **No `pubs_timeout` on the live-tail subscribers.** `EventBus.max_queue_size = 256` with drop-oldest is fine for a UI stream. But the API surface doesn't expose that knob, and a single stuck subscriber can drop events silently (it is the documented behavior — "if a subscriber's per-kind queue is full, the oldest event is dropped"). The "stalled" / "error" pill in the UI catches connection-level stalls, not queue-overflow drops.
7. **No 277CA support.** Of all the missing transaction types, this is the one the user will feel the most. A 999 says "syntax OK"; a 277CA says "claim CLM-001 accepted / CLM-002 rejected for reason X." Without 277CA, the operator's only feedback on rejected claims is *their own 999 R/E counts and the manual inbox.* That's enough for self-billed files, but a real round-trip with a payer needs 277CA.
8. **No real-time eligibility round-trip.** The 270 builder + 271 parser exist as a *file-exchange* pair, not as a *call-response* workflow. There is no SFTP pickup, no SOAP client, no retry, no polling. The README is accurate (API-only) but a "eligibility verification" feature is conspicuously absent.
9. **PayerConfig lives in code.** `cyclone/parsers/payer.py:163` holds `co_medicaid()`. Adding a payer means editing Python. An industry clearinghouse loads companion guides as data (CSV/YAML) so non-engineers can update rules.
10. **No NPI validation.** NPIs are captured (`claim.party.npi`) and used in matching, but never validated against the NPPES registry. Bad NPIs (typos, deactivated) silently propagate.
11. **No vocabulary checks.** HCPCS codes are stored as strings; there is no HCPCS vocabulary table. Same for ICD-10 diagnoses. The parser catches shape; it does not catch "9921A" or "Z00.0XX" (invalid ICD-10). This is fine for "show me what I got" but not for "is this a valid claim."
12. **No COB / secondary-claim generation.** A 835 with `CO-22` (care paid by another payer) or `COB` carve-out lands as `patient_responsibility` and stops. There is no generator for a secondary 837 to the next payer down. This is a real billing-office workflow and it's missing.
13. **Reverse-direction 835 (file-in only).** Cyclone reads 835s; it does not generate 835s. Real providers occasionally need to generate an 835 (e.g., for a non-EDI payer via a clearinghouse portal — the provider-side "I am the payer" use case). Out of scope, but worth flagging.
14. **No 997 fallback.** The roadmap says "997 deferred" but 997 is replaced by 999 in 5010. 999 ships. 997 is correctly not a gap.
15. **Score weights are hardcoded** (`scoring.py:43-47`). The 40/25/20/15 split is good but is in code. A real operator will want to tune these per payer; that requires editing Python.
16. **No config file / no per-payer config persistence.** The "generic_837p" PayerConfig exists but is not actually loaded by default; the API defaults to `co_medicaid`. A different real payer would need a code change.
17. **`store.py` is 2,086 lines.** That's a god-module. The migration from `InMemoryStore` to `CycloneStore` to per-method DB-session management is the biggest single file. Splitting into `store_persistence.py`, `store_reconcile.py`, `store_queries.py` would help.
18. **The 6 migrations are in a flat directory** without a manifest. Adding `0007_*.sql` requires manual ordering discipline. A `migrations.json` with checksums would be more robust.
19. **`api.py` is a monolithic 1,000+ LOC FastAPI surface.** All endpoints, all content negotiation, all CORS, all lifespan, all route handlers in one file. A `routers/` package split would help (e.g., `routers/parse.py`, `routers/inbox.py`, `routers/reconciliation.py`, `routers/stream.py`).
20. **The README still says "Sub-projects 2 through 8 are shipped"** — true — but it does not say *what's next*. The roadmap section is a list of past SPs. A "What we explicitly are not building" section would set expectations.
### 3.2 Minor
21. **`PayerConfig.allowed_status_codes` is validated but not surfaced to the UI** in a way that explains *why* a status code is in the bucket. An operator looking at a 999 with code "E" (accepted-with-errors) sees the label but not the rule.
22. **CAS reason codes are a static Python dict** (`cas_codes.py` with `LAST_UPDATED = "2026-06-20"`). Annual refresh is a manual edit. A loader from X12.org's published CSV (or a vendored snapshot) would be more maintainable.
23. **The HIPAA `PII` warning text in `tests/fixtures/` is missing** — prodfiles contain real-looking patient data. Even in a single-operator tool, fixtures should be flagged as PHI.
24. **`/api/health` is described as "(unchanged)"** but not implemented to surface DB connection status, last-batch timestamp, or pubsub health. A richer health check would be useful for the operator's "is the system actually running" sense.
25. **No CSP / no security headers.** CORS is set; the rest of the FastAPI defaults are in play. Even a `127.0.0.1` app benefits from a default `X-Content-Type-Options: nosniff` etc.
26. **The `TailStatusPill` and reconnect UX is excellent**, but the "stalled" state fires after 30s of total silence including heartbeats. The user-facing implication ("the stream is dead") is correct, but a *graceful close on tab close* signal is not in the protocol — the server doesn't know the tab went away until the TCP socket dies.
27. **The "fallback to in-memory sample store" path** in the frontend (when `VITE_API_BASE_URL` is empty) is documented as a transitional dev convenience. It should be removed in a future sub-project — having two data sources of truth is a foot-gun.
28. **No license file.** The README acknowledges this. Worth fixing for a public-facing repo.
29. **No Dockerfile / no `docker-compose.yml`.** The single-binary local deploy is fine, but a Docker image with `127.0.0.1` exposed would make the tool portable without surprising the operator about host firewall implications.
30. **No `CONTRIBUTING.md`, `pre-commit` config, `Makefile`, `.editorconfig`, or `ruff` config.** Operational hygiene for a multi-contributor project; not needed for a single-operator project.
---
## 4. Strengths — to credit the design
A pure-gaps list misrepresents the project. Things the design gets right:
1. **Round-trip guarantee** on the 837P serializer (113 prodfiles parse → serialize → parse). That's a strong property and the README is honest about what "round-trip" means (canonical fields, not byte-identity — the spec calls this out explicitly).
2. **574 backend tests + ~155 frontend test files** with real prodfiles, not just minimal fixtures. The test density is unusually high for a single-operator tool.
3. **No silent failures.** Every reconciliation anomaly writes an `ActivityEvent`. The README is emphatic about this and the code honors it.
4. **EventBus is well-designed** for single-process use — drop-oldest, async-first, explicit `subscribe_raw` / `unsubscribe` pairing. The doc comment about why `subscribe_raw` exists is the kind of comment that prevents a real bug.
5. **Pure-function reconciliation** (`reconcile.match`, `apply_payment`, `apply_reversal`) is unit-testable without a DB. The spec called this out and the implementation honors it.
6. **7-state claim lifecycle** with `state_before_reversal` preserves the audit trail. Reversals don't lose information.
7. **Migration runner via `PRAGMA user_version`** is the right choice for SQLite at this scale. No Alembic ceremony.
8. **Layered claim validation** (parser + validator + reconciliation + line-level audit) catches errors at the right layer instead of one mega-validator.
9. **Ticker-Tape UI** is polished, accessible, keyboard-driven, URL-synced. The aesthetic decisions (Cabinet Grotesk + Geist Mono, true-black ground, electric-blue accent, hairline chrome) are coherent and the design specs enforce them.
10. **The "fail-soft" pattern** — reconciliation crashes don't lose the 835, the activity event records the failure — is the right HIPAA-aligned posture even though there's no HIPAA layer yet.
11. **CORS allowlist is exact** (not `*`), bind is `127.0.0.1`, no auth. The threat model is "process on the same host" and the design matches the threat model.
12. **The docs are exemplary.** Every sub-project has a design spec + implementation plan + smoke test. A new operator can read the project top-to-bottom.
13. **The serializer's hybrid approach** (rebuild editable segments, splice stable segments from `raw_segments`) is pragmatic — it propagates edits without re-emitting the entire hierarchy.
14. **The `proc · #N` line reference format** in the CAS panel is small but it shows the team thinks about how the operator will read the data, not just how the database stores it.
15. **The "lane" metaphor for the inbox** (Rejected / Candidates / Unmatched / Done today) is the right operator model. Real billing offices think in those terms.
---
## 5. What's "next" — what to do about the gaps
The honest answer depends on what the operator wants. Three options:
### 5.1 Option A — Stay local, deepen what's there
If the goal is "best-in-class single-operator X12 file viewer + parser + regenerator," the gaps worth filling are:
- **277CA parser.** Single biggest value-add for the inbox workflow. Pairs naturally with the existing 999 parser.
- **Encryption at rest** via SQLCipher or similar. One-shot, big reduction in PHI exposure.
- **Automated, encrypted backup** with retention. A tiny cron + age-out, plus a restore-drill script.
- **NPPES NPI validation** at parse time. One NPI list file (downloaded monthly) + a quick check.
- **NDC / ICD-10 vocabulary tables** for code-set validation. Loaded from a vendored CSV.
- **COB / secondary-claim generator** — turn an 835 with `patient_responsibility` into a 837 to the secondary payer.
- **Real-time eligibility round-trip** — SFTP pickup + SOAP client + retry + 271 parse. The 270 builder is already there; the missing piece is *outbound*.
- **279 / 278 / 276/277** transaction types, if real-payer expansion is on the roadmap.
- **EHR/PM integration** — a simple webhook in, webhook out, and a `POST /api/parse-837` that's callable from Epic/Athena.
### 5.2 Option B — Become a real clearinghouse
This is a different project. The minimum feature set is:
- All 10 X12 5010 transaction types (837 P/I/D, 835, 270/271, 276/277, 277CA, 278, 834, 820).
- AS2/AS4 connectivity with MDN/non-repudiation.
- SFTP, HTTPS, VPN gateways.
- HITRUST or SOC 2 Type II.
- BAA template, vendor-risk management, breach notification policy.
- 100+ payer companion-guide rules as data, not code.
- SNIP 17 validation pipeline.
- CCI / medical-necessity / duplicate-detection edits.
- Multi-tenant (or at least multi-customer) data isolation.
- HA, DR, observability, alerting, on-call.
- Real-time eligibility round-trip with CAQH CORE Phase II/III SOAP envelopes.
- Reporting/analytics: clean-claim rate, denial trends, AR aging, ERA-lag, payer scorecards.
- Sales, marketing, support, billing.
This is a 1050-engineer, multi-year effort. Cyclone's codebase would contribute the parser layer (X12 tokenization, models, validators, serializers) and the data model. The rest — connectivity, security, multi-tenancy, sales — is a different product. The good news: the parser is genuinely good. The bad news: "good parser" is maybe 15% of what a clearinghouse is.
### 5.3 Option C — Pivot to "EDI developer toolkit"
A third option, smaller than Option B: ship the parser + serializer + scoring + line-level audit as a library (Python package + npm package) and let *other* people build the connectivity and security layers. Cyclone's `cyclone.parsers` is already structured for this — Pydantic v2 models, pure-function validators, no FastAPI dependency in the parsers themselves. A `pip install cyclone-edi` would be a real product.
---
## 6. Recommendation
The project is **complete for its designed scope** and **incomplete for the industry sense of the word "clearinghouse."** Both statements are true. The README is clear about which scope it ships to; the design specs are clear about what's out; the test suite is honest.
The single most valuable next step — independent of the option above — is **277CA**. It is a transaction the existing data model already implies (the 999 R/E counts are a coarse substitute), it fits the existing parser architecture, and it closes the operator's most painful feedback loop ("did the payer actually accept this claim, or did it just syntactically parse?"). After 277CA, encryption-at-rest is the next biggest win — one PR, big reduction in PHI risk.
Beyond that, the next steps depend on whether the goal is *deeper single-operator tool* (Option A), *real clearinghouse* (Option B — not a project, a company), or *library/SDK* (Option C — modest scope, real market).
---
## 7. Reviewer notes
- This review is read-only; no code was modified.
- All file references use the path at session time (`/Users/openclaw/dev/cyclone/...`).
- The "industry" framing is the question the user asked, not the project's own framing. Cyclone's own design contract (single-operator, single-host, single-payer, no auth) is the right contract for the project; this review documents what *would* be needed to expand that contract, not what *should* be done to fix the existing design.
- Sources for §2: HHS HIPAA Security Rule pages, CMS Adopted Standards page, X12.org RFI #2099, CGS Medicare 276/277 companion guide, Fallon Health 276/277 companion guide, Novitas-Solutions 999/277CA references, PilotFish 999 format guide, Nirmitee 277 automation blog, Stedi real-time claim status API docs, Optum 837 companion guide, Invene 9 critical transactions, AccountableHQ 5010 explainers, MedibillRCM clearinghouse comparison, Trubridge clearinghouse definitions, HIPAA Journal clearinghouse and encryption articles, HealthCare PilotFish 277 example, HIPAA Vault EDI cloud hosting, AccountableHQ Zero Trust in healthcare, AnatomyIT Zero Trust, HHS cloud-computing guidance. X/Twitter yielded limited relevant public discussion of EDI; the public technical conversation is mostly on LinkedIn, vendor portals, and payer companion guides.
- Test count verified via `pytest --collect-only` on `backend/`: **574 tests collected**, 0.69s.
- Backend LOC verified: `cyclone/` ≈ 12,935 lines across 28 Python files; `store.py` alone is 2,086 lines.
- Migration files verified: 6 SQL files in `backend/src/cyclone/migrations/`, `PRAGMA user_version` currently 6.
---
*End of review.*
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,600,700&f[]=geist-mono@400,500,600&display=swap"
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet"
/>
</head>
+171
View File
@@ -13,6 +13,7 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.101.0",
"ansi-styles": "^6.2.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
@@ -24,6 +25,7 @@
"zustand": "^4.5.5"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/node": "^22.7.7",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -285,6 +287,16 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -2202,6 +2214,55 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -2213,6 +2274,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2445,6 +2514,29 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -2485,6 +2577,17 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2773,6 +2876,17 @@
}
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2803,6 +2917,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.376",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
@@ -3481,6 +3603,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3824,6 +3957,36 @@
"dev": true,
"license": "MIT"
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3870,6 +4033,14 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+2
View File
@@ -18,6 +18,7 @@
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.101.0",
"ansi-styles": "^6.2.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
@@ -29,6 +30,7 @@
"zustand": "^4.5.5"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/node": "^22.7.7",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+45 -25
View File
@@ -15,30 +15,49 @@ import { cn } from "@/lib/utils";
const kindConfig: Record<
Activity["kind"],
{ icon: LucideIcon; tone: string }
{ icon: LucideIcon; tone: string; tint: string }
> = {
claim_submitted: { icon: FileText, tone: "text-[hsl(var(--accent))]" },
claim_accepted: { icon: CheckCircle2, tone: "text-[hsl(var(--success))]" },
claim_paid: { icon: Banknote, tone: "text-[hsl(var(--success))]" },
claim_denied: { icon: XCircle, tone: "text-destructive" },
remit_received: { icon: Pill, tone: "text-[hsl(var(--warning))]" },
provider_added: { icon: UserPlus, tone: "text-foreground" },
// `manual_match` is emitted by the backend when an operator manually
// pairs a remit to a claim from the Reconciliation page — see
// `cyclone.store` `record_manual_match`. Wrench is the closest fit;
// we keep it neutral-tone so it doesn't read as a status.
manual_match: { icon: Wrench, tone: "text-muted-foreground" },
claim_submitted: {
icon: FileText,
tone: "text-[hsl(var(--accent))]",
tint: "bg-accent/10",
},
claim_accepted: {
icon: CheckCircle2,
tone: "text-[hsl(var(--success))]",
tint: "bg-[hsl(var(--success)/0.12)]",
},
claim_paid: {
icon: Banknote,
tone: "text-[hsl(var(--success))]",
tint: "bg-[hsl(var(--success)/0.12)]",
},
claim_denied: {
icon: XCircle,
tone: "text-destructive",
tint: "bg-destructive/12",
},
remit_received: {
icon: Pill,
tone: "text-[hsl(var(--warning))]",
tint: "bg-[hsl(var(--warning)/0.12)]",
},
provider_added: {
icon: UserPlus,
tone: "text-foreground",
tint: "bg-muted/60",
},
manual_match: {
icon: Wrench,
tone: "text-muted-foreground",
tint: "bg-muted/40",
},
};
// Defensive fallback: if the backend ever emits a kind this UI doesn't
// know about (e.g. a new kind landed server-side before the frontend
// picked it up), render a neutral icon instead of crashing the whole
// Activity Log page. The kindConfig map above stays exhaustive for
// known kinds so TypeScript still catches missing entries at compile
// time; this only protects the runtime path.
const FALLBACK_KIND: { icon: LucideIcon; tone: string } = {
const FALLBACK_KIND: { icon: LucideIcon; tone: string; tint: string } = {
icon: Circle,
tone: "text-muted-foreground",
tint: "bg-muted/40",
};
export function ActivityFeed({
@@ -50,7 +69,7 @@ export function ActivityFeed({
}) {
if (items.length === 0) {
return (
<div className="text-sm text-muted-foreground px-2 py-6 text-center">
<div className="text-[13px] text-muted-foreground px-2 py-6 text-center">
{emptyMessage}
</div>
);
@@ -67,28 +86,29 @@ export function ActivityFeed({
>
<div
className={cn(
"mt-0.5 h-7 w-7 shrink-0 rounded-md bg-muted/50 flex items-center justify-center",
"mt-0.5 h-7 w-7 shrink-0 rounded-md ring-1 ring-inset ring-border/40 flex items-center justify-center",
cfg.tint,
cfg.tone
)}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground/95 leading-snug">
<div className="text-[13px] text-foreground/95 leading-snug">
{a.message}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-2">
<div className="mono text-[10.5px] text-muted-foreground mt-0.5 flex items-center gap-2">
<span>{fmt.relative(a.timestamp)}</span>
{a.npi ? (
<>
<span className="opacity-40">·</span>
<span className="num">{a.npi}</span>
<span>{a.npi}</span>
</>
) : null}
{a.amount ? (
<>
<span className="opacity-40">·</span>
<span className="num">{fmt.usd(a.amount)}</span>
<span>{fmt.usd(a.amount)}</span>
</>
) : null}
</div>
+20 -24
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ApiError } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useClaimDetail } from "@/hooks/useClaimDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { useLineReconciliation } from "@/hooks/useLineReconciliation";
@@ -145,7 +146,7 @@ export function ClaimDrawer({
// `aria-describedby={undefined}` suppresses the Radix warning
// about a missing description — the drawer content is its own
// description and there's nothing useful to point at.
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)] bg-[color:var(--m-surface)] p-0"
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
data-testid="claim-drawer"
aria-describedby={undefined}
>
@@ -166,47 +167,42 @@ export function ClaimDrawer({
>
<ClaimDrawerHeader claim={data} onClose={onClose} />
<div
className="flex gap-2 px-6 pt-4 border-b"
style={{ borderColor: "var(--tt-bg)" }}
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
data-testid="claim-drawer-tabs"
>
<button
type="button"
onClick={() => setActiveTab("details")}
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
style={{
color:
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "details"
? "var(--tt-ink, currentColor)"
: "var(--tt-ink-dim, rgba(255,255,255,0.5))",
borderBottom:
activeTab === "details"
? "2px solid var(--tt-amber)"
: "2px solid transparent",
}}
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
)}
data-testid="tab-button-details"
data-active={activeTab === "details" ? "true" : "false"}
>
Details
{activeTab === "details" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
) : null}
</button>
<button
type="button"
onClick={() => setActiveTab("line-reconciliation")}
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
style={{
color:
className={cn(
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
activeTab === "line-reconciliation"
? "var(--tt-ink, currentColor)"
: "var(--tt-ink-dim, rgba(255,255,255,0.5))",
borderBottom:
activeTab === "line-reconciliation"
? "2px solid var(--tt-amber)"
: "2px solid transparent",
}}
? "text-[color:var(--surface-ink)]"
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
)}
data-testid="tab-button-line-reconciliation"
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
>
Line Reconciliation
{activeTab === "line-reconciliation" ? (
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
) : null}
</button>
</div>
{activeTab === "line-reconciliation" ? (
@@ -228,7 +224,7 @@ export function ClaimDrawer({
<LineReconciliationTab data={lr.data} />
) : null
) : (
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
<ValidationPanel validation={data.validation} />
<ServiceLinesTable
serviceLines={data.serviceLines}
@@ -42,11 +42,11 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-error)]">
<span className="eyebrow text-[color:var(--m-error)]">
{eyebrow}
</span>
</div>
<p className="text-sm text-[color:var(--m-ink-secondary)]">{message}</p>
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
@@ -166,17 +166,18 @@ describe("ClaimDrawerHeader", () => {
stateLabel: string;
expectedToken: string;
}> = [
// secondary → bg-muted text-muted-foreground
{ state: "submitted", stateLabel: "Submitted", expectedToken: "bg-muted text-muted-foreground" },
// secondary → bg-muted/80 text-muted-foreground
// (the polished muted treatment uses /80 for a slightly subtler tone)
{ state: "submitted", stateLabel: "Submitted", expectedToken: "bg-muted/80 text-muted-foreground" },
// default → bg-primary/15 text-primary
{ state: "accepted", stateLabel: "Accepted", expectedToken: "bg-primary/15" },
// destructive → bg-destructive/15
{ state: "denied", stateLabel: "Denied", expectedToken: "bg-destructive/15" },
// success → bg-[hsl(var(--success)/0.15)] (covered by both matched and paid)
{ state: "matched", stateLabel: "Matched", expectedToken: "bg-[hsl(var(--success)/0.15)]" },
{ state: "paid", stateLabel: "Paid", expectedToken: "bg-[hsl(var(--success)/0.15)]" },
// warning → bg-[hsl(var(--warning)/0.15)]
{ state: "pending", stateLabel: "Pending", expectedToken: "bg-[hsl(var(--warning)/0.15)]" },
// success → bg-[hsl(var(--success)/0.14)] (covered by both matched and paid)
{ state: "matched", stateLabel: "Matched", expectedToken: "bg-[hsl(var(--success)/0.14)]" },
{ state: "paid", stateLabel: "Paid", expectedToken: "bg-[hsl(var(--success)/0.14)]" },
// warning → bg-[hsl(var(--warning)/0.14)]
{ state: "pending", stateLabel: "Pending", expectedToken: "bg-[hsl(var(--warning)/0.14)]" },
];
for (const c of cases) {
@@ -84,20 +84,19 @@ export function ClaimDrawerHeader({
<header
className={cn(
"flex items-start justify-between gap-4 px-6 py-5",
"border-b border-[color:var(--m-border-heavy)]",
"border-b border-[color:var(--m-border-heavy)]/40",
"bg-[color:var(--m-surface)]"
)}
data-testid="claim-drawer-header"
>
{/* Left: eyebrow + mono claim ID */}
<div className="flex flex-col gap-1 min-w-0">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
Claim
</span>
<span
data-testid="header-id"
className="font-mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
>
{claim.id}
</span>
@@ -115,8 +114,7 @@ export function ClaimDrawerHeader({
</Badge>
<span
data-testid="header-amount"
className="font-mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
>
{fmt.usdPrecise(claim.billedAmount)}
</span>
@@ -62,9 +62,10 @@ describe("DiagnosesList", () => {
expect(text).toContain("(3)");
// Eyebrow style (uppercase + tracking) — sniff the className.
// The shared `.eyebrow` class in index.css consolidates the
// eyebrow treatment; tests assert it lands on the section label.
const cls = label?.className ?? "";
expect(cls).toContain("uppercase");
expect(cls).toContain("tracking-[0.18em]");
expect(cls).toContain("eyebrow");
unmount();
});
+10 -5
View File
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Diagnoses ({diagnoses.length})
</h3>
@@ -55,18 +55,23 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
<li
key={`${d.code}-${i}`}
data-testid="diagnosis-item"
className="flex items-baseline gap-2 text-sm"
className="flex items-baseline gap-3 text-sm"
>
<span
className="font-mono text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-[color:var(--m-ink-primary)] font-medium"
>
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
</span>
{desc ? (
<span className="text-[color:var(--m-ink-secondary)]">
<>
<span
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
aria-hidden
/>
<span className="text-[color:var(--m-ink-secondary)] italic">
{desc}
</span>
</>
) : null}
</li>
);
@@ -26,31 +26,37 @@ export function LineReconciliationTab({
return (
<section className="flex flex-col gap-4 px-6 py-4">
<header
className="flex items-baseline justify-between"
className="flex items-baseline justify-between flex-wrap gap-3"
data-testid="line-reconciliation-summary"
>
<div className="flex gap-6 font-mono tabular-nums text-sm">
<span data-testid="billed-total">
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
<div className="flex gap-5 font-mono tabular-nums text-sm">
<span data-testid="billed-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
Billed
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
{fmt.usdPrecise(Number(summary.billedTotal))}
</span>
<span data-testid="paid-total">
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
</span>
<span data-testid="paid-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
Paid
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
{fmt.usdPrecise(Number(summary.paidTotal))}
</span>
<span data-testid="adjustments-total">
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
</span>
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
Adjustments
</span>
<span className="text-[color:var(--m-ink-primary)] font-semibold">
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
</span>
</span>
</div>
<span
className="font-mono text-xs uppercase tracking-wider"
className="font-mono text-xs uppercase tracking-[0.14em]"
style={{
color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)",
}}
@@ -64,7 +70,7 @@ export function LineReconciliationTab({
<div className="grid grid-cols-2 gap-4">
<div data-testid="left-column">
<h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
Billed (837 SV1)
</h3>
{leftRows.map((row) => (
@@ -72,7 +78,7 @@ export function LineReconciliationTab({
))}
</div>
<div data-testid="right-column">
<h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
Adjudicated (835 SVC)
</h3>
{rightRows.map((row) => (
@@ -108,7 +114,7 @@ function LineCard({
return (
<div
className="flex flex-col gap-1 py-2 px-3 mb-2"
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5"
style={{
borderLeft: `3px solid ${accentColor}`,
background: "rgba(255,255,255,0.02)",
@@ -117,7 +123,7 @@ function LineCard({
>
<div className="flex items-baseline justify-between">
<div className="font-mono text-sm">
<span className="font-semibold">{procCode}</span>
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span>
{lineNumber !== null ? (
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
#{lineNumber}
@@ -140,7 +146,7 @@ function LineCard({
</span>
) : null}
</div>
<div className="font-mono tabular-nums text-xs">
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold">
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
</div>
@@ -149,7 +155,8 @@ function LineCard({
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
{row.adjustments.map((a, i) => (
<li key={i}>
{a.groupCode} · {a.reasonCode} · {fmt.usdPrecise(Number(a.amount))}
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
</li>
))}
</ul>
@@ -69,9 +69,10 @@ describe("MatchedRemitCard", () => {
expect(text).toContain("matched remittance");
// Eyebrow style (uppercase + tracking) — sniff the className.
// The shared `.eyebrow` class in index.css consolidates the
// eyebrow treatment; tests assert it lands on the section label.
const cls = label?.className ?? "";
expect(cls).toContain("uppercase");
expect(cls).toContain("tracking-[0.18em]");
expect(cls).toContain("eyebrow");
unmount();
});
@@ -56,21 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Matched Remittance
</h3>
<div
data-testid="matched-remit-card-inner"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] p-4 sm:flex-row sm:items-start sm:justify-between"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between"
>
{/* Left: remit id + status badge + received date */}
<div className="flex min-w-0 flex-col gap-2">
<span
data-testid="matched-remit-id"
className="font-mono text-sm text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-sm text-[color:var(--m-ink-primary)]"
>
{id}
</span>
@@ -95,8 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
<div className="flex flex-col items-start gap-2 sm:items-end">
<span
data-testid="matched-remit-total-paid"
className="font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums"
>
{fmt.usdPrecise(totalPaid)}
</span>
@@ -105,6 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
size="sm"
onClick={handleViewRemittance}
data-testid="view-remittance-link"
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
>
View remittance
<ArrowRight
@@ -120,7 +119,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
<span
data-testid="matched-remit-line-counts"
data-all-matched={allMatched ? "true" : "false"}
className="font-mono text-xs uppercase tracking-wider"
className="mono text-xs uppercase tracking-[0.14em]"
style={{ color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)" }}
>
lines: {matchedLines}/{totalLines} matched
@@ -86,9 +86,10 @@ describe("PartiesGrid", () => {
expect(text).toContain("parties");
// Eyebrow style (uppercase + tracking) — sniff the className.
// The shared `.eyebrow` class in index.css consolidates the
// eyebrow treatment; tests assert it lands on the section label.
const cls = label?.className ?? "";
expect(cls).toContain("uppercase");
expect(cls).toContain("tracking-[0.18em]");
expect(cls).toContain("eyebrow");
unmount();
});
+6 -7
View File
@@ -25,7 +25,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
const a = address as ClaimDetailAddress;
return (
<div
className="text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
data-testid="party-address"
>
<div>{a.line1}</div>
@@ -58,17 +58,16 @@ function PartyCard({
return (
<div
data-testid={testId}
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] px-4 py-3"
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
>
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
{label}
</span>
<div className="text-base font-medium text-[color:var(--m-ink-primary)]">
<div className="display text-lg text-[color:var(--m-ink-primary)]">
{name}
</div>
<div
className="font-mono text-[13px] text-[color:var(--m-ink-secondary)] tabular-nums"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
>
{identity}
</div>
@@ -97,7 +96,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Parties
</h3>
@@ -52,9 +52,10 @@ describe("RawSegmentsPanel", () => {
expect(text).toContain("(3)");
// Eyebrow style (uppercase + tracking) — sniff the className.
// The shared `.eyebrow` class in index.css consolidates the
// eyebrow treatment; tests assert it lands on the section label.
const cls = label?.className ?? "";
expect(cls).toContain("uppercase");
expect(cls).toContain("tracking-[0.18em]");
expect(cls).toContain("eyebrow");
unmount();
});
@@ -33,16 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Raw Segments ({rawSegments.length})
</h3>
<details className="rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]">
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50">
<summary
className="cursor-pointer select-none px-4 py-2 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
data-testid="raw-segments-summary"
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" />
{rawSegments.length === 0
? `Show raw segments`
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
@@ -58,7 +59,7 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
) : (
<pre
data-testid="raw-segments-pre"
className="overflow-x-auto whitespace-pre-wrap px-4 pb-3 pt-1 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{rawSegments.map((segment, i) => (
@@ -68,9 +68,10 @@ describe("ServiceLinesTable", () => {
expect(text).toContain("(3)");
// Eyebrow style (uppercase + tracking) — sniff the className.
// The shared `.eyebrow` class in index.css consolidates the
// eyebrow treatment; tests assert it lands on the section label.
const cls = label?.className ?? "";
expect(cls).toContain("uppercase");
expect(cls).toContain("tracking-[0.18em]");
expect(cls).toContain("eyebrow");
unmount();
});
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
<section className="flex flex-col gap-3 px-6 py-4">
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Service Lines ({serviceLines.length})
</h3>
@@ -82,6 +82,7 @@ export function ServiceLinesTable({
key={line.lineNumber}
data-testid="service-line-row"
data-line-number={line.lineNumber}
className="row-hover"
>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
{line.lineNumber}
@@ -90,7 +91,7 @@ export function ServiceLinesTable({
{formatProcedure(line)}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(line.charge)}
@@ -65,9 +65,10 @@ describe("StateHistoryTimeline", () => {
expect(text).toContain("(3)");
// Eyebrow style (uppercase + tracking) — sniff the className.
// The shared `.eyebrow` class in index.css consolidates the
// eyebrow treatment; tests assert it lands on the section label.
const cls = label?.className ?? "";
expect(cls).toContain("uppercase");
expect(cls).toContain("tracking-[0.18em]");
expect(cls).toContain("eyebrow");
unmount();
});
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
State History ({history.length})
</h3>
@@ -79,7 +79,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
) : (
<ol
data-testid="state-history-timeline"
className="relative ml-2 flex flex-col gap-3 border-l-2 border-[color:var(--m-border-heavy)] pl-5"
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6"
>
{history.map((event, i) => (
<li
@@ -91,19 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
<span
data-testid="state-history-dot"
aria-hidden
className={`absolute -left-[25px] top-1.5 h-2 w-2 rounded-full ${dotColorFor(event.kind)}`}
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`}
/>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
data-testid="state-history-kind"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]"
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]"
>
{kindLabel(event.kind)}
</span>
<span
data-testid="state-history-ts"
className="font-mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
>
{fmt.date(event.ts)} · {fmt.time(event.ts)}
</span>
@@ -111,10 +110,9 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
{event.remittanceId ? (
<span
data-testid="history-remit-id"
className="font-mono text-xs text-[color:var(--m-ink-tertiary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-xs text-[color:var(--m-ink-tertiary)]"
>
Remit {event.remittanceId}
Remit {event.remittanceId}
</span>
) : null}
</li>
+10 -8
View File
@@ -48,16 +48,15 @@ function IssueGroup({
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span
className="font-mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
>
{rule}
</span>
<span
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 text-[10.5px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
data-testid={`${testId}-count`}
>
({issues.length})
{issues.length}
</span>
</div>
<ul className="flex flex-col gap-1.5 pl-1">
@@ -109,6 +108,9 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]">
All checks passed
</span>
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]">
Valid
</span>
</section>
);
}
@@ -118,10 +120,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
return (
<section
className="flex flex-col gap-3 px-6 py-4 bg-[color:var(--m-surface)]"
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]"
data-testid="validation-panel"
>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
Validation
</span>
@@ -130,7 +132,7 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
data-testid="validation-errors"
data-rule-group="errors"
role="alert"
className="flex flex-col gap-2 border-l-2 border-[color:var(--m-error)] bg-[color:var(--m-error-bg)] px-3 py-2.5"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3"
>
<div className="flex items-center gap-2">
<AlertCircle
@@ -159,7 +161,7 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
<div
data-testid="validation-warnings"
data-rule-group="warnings"
className="flex flex-col gap-2 border-l-2 border-[color:var(--m-warning)] bg-[color:var(--m-warning-bg)] px-3 py-2.5"
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-warning)] bg-[hsl(var(--warning)/0.08)] px-3.5 py-3"
>
<div className="flex items-center gap-2">
<AlertTriangle
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { Keyboard, X } from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type KeyboardCheatsheetProps = {
open: boolean;
@@ -84,8 +85,8 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
<DialogContent
// Centered modal (DialogContent's default positioning is
// `left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`).
// We narrow it to max-w-md and apply the modern palette so
// it sits on the same surface as the rest of SP4.
// We narrow it to max-w-md and apply the warm-paper palette so
// it sits on the same surface as the rest of the detail surfaces.
//
// a11y wiring:
// - `aria-labelledby` points at the visible <h2> below so
@@ -94,21 +95,27 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
// - `aria-describedby={undefined}` suppresses Radix's
// missing-description warning; the cheatsheet's content
// (key list + helper text) is its own description.
className="max-w-md bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)] border border-[color:var(--m-border-heavy)]/20"
className={cn(
"max-w-md p-5",
"bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]",
"border border-[color:var(--m-border-heavy)]/20",
"shadow-[0_24px_64px_-12px_rgb(0_0_0_/_0.5),inset_0_1px_0_0_rgb(255_255_255_/_0.6)]"
)}
aria-labelledby={CHEATSHEET_TITLE_ID}
aria-describedby={undefined}
data-testid="keyboard-cheatsheet"
>
<header className="flex items-center justify-between gap-2 mb-3 pr-10">
<header className="flex items-center justify-between gap-2 mb-4 pr-10 pb-3 border-b border-[color:var(--m-border-heavy)]/15">
<div className="flex items-center gap-2">
<Keyboard
className="h-4 w-4 text-[color:var(--m-ink-secondary)]"
strokeWidth={1.75}
<span
aria-hidden
/>
className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-[color:var(--m-ink-primary)]/8 text-[color:var(--m-ink-secondary)]"
>
<Keyboard className="h-3 w-3" strokeWidth={2} />
</span>
<h2
id={CHEATSHEET_TITLE_ID}
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-secondary)]"
>
Keyboard
</h2>
@@ -134,11 +141,11 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
</button>
</header>
<ul className="flex flex-col gap-2.5">
<ul className="flex flex-col gap-1">
{SHORTCUTS.map((s) => (
<li
key={s.description}
className="flex items-center justify-between gap-3 text-sm"
className="flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-[color:var(--m-ink-primary)]/[0.04]"
>
<div className="flex items-center gap-1.5">
{s.keys.map((k) => (
@@ -147,15 +154,15 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
</kbd>
))}
</div>
<span className="text-[color:var(--m-ink-secondary)]">
<span className="text-[13px] text-[color:var(--m-ink-primary)]">
{s.description}
</span>
</li>
))}
</ul>
<p className="mt-4 text-xs text-[color:var(--m-ink-tertiary)]">
Press any other key to dismiss.
<p className="mt-4 pt-3 border-t border-[color:var(--m-border-heavy)]/15 eyebrow text-[color:var(--m-ink-tertiary)]">
Press any other key to dismiss
</p>
</DialogContent>
</Dialog>
+38 -13
View File
@@ -10,10 +10,19 @@ interface KpiCardProps {
hint?: string;
icon?: LucideIcon;
sparkline?: number[];
accent?: "default" | "success" | "warning" | "destructive" | "accent";
className?: string;
style?: React.CSSProperties;
}
/**
* KPI tile. Five parts top-to-bottom:
* 1. Eyebrow label + small icon — both uppercased
* 2. Big number — Geist Mono, the headline
* 3. Delta + hint row — one is always present
* 4. Sparkline strip — bottom, hairline-separated
* 5. (Optional) Accent line on the left edge
*/
export function KpiCard({
label,
value,
@@ -21,44 +30,60 @@ export function KpiCard({
hint,
icon: Icon,
sparkline,
accent = "default",
className,
style,
}: KpiCardProps) {
const accentColor = {
default: "hsl(var(--accent))",
accent: "hsl(var(--accent))",
success: "hsl(var(--success))",
warning: "hsl(var(--warning))",
destructive: "hsl(var(--destructive))",
}[accent];
return (
<div
style={style}
className={cn(
"surface rounded-xl p-5 flex flex-col gap-3 transition-colors hover:bg-muted/20",
"group relative surface-2 rounded-xl p-5 flex flex-col gap-3 transition-colors hover:bg-muted/20 overflow-hidden",
className
)}
>
{/* Left accent line — the only "color" element on the card, and
only present when the value is significant (denial rate,
etc.). Width 2px so it reads as a marker, not decoration. */}
<div
aria-hidden
className="absolute left-0 top-4 bottom-4 w-px"
style={{ backgroundColor: accentColor, opacity: 0.45 }}
/>
<div className="flex items-center justify-between">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
<div className="eyebrow">{label}</div>
{Icon ? (
<div className="h-6 w-6 rounded-md bg-muted/60 flex items-center justify-center">
<Icon className="h-3 w-3 text-muted-foreground" strokeWidth={1.75} />
<div className="h-6 w-6 rounded-md bg-muted/50 ring-1 ring-inset ring-border/40 flex items-center justify-center text-muted-foreground">
<Icon className="h-3 w-3" strokeWidth={1.75} />
</div>
) : null}
</div>
<div className="display text-[32px] leading-none text-foreground mt-0.5">
<div className="display mono text-[32px] leading-[1.05] text-foreground tracking-tight">
{value}
</div>
<div className="flex items-center gap-2 text-xs min-h-[16px]">
<div className="flex items-center gap-2 text-[11px] min-h-[16px]">
{delta ? (
<span
className={cn(
"inline-flex items-center gap-0.5 font-medium num",
"inline-flex items-center gap-0.5 font-medium mono",
delta.positive ? "text-[hsl(var(--success))]" : "text-destructive"
)}
>
{delta.direction === "up" ? (
<ArrowUpRight className="h-3 w-3" />
<ArrowUpRight className="h-3 w-3" strokeWidth={2} />
) : (
<ArrowDownRight className="h-3 w-3" />
<ArrowDownRight className="h-3 w-3" strokeWidth={2} />
)}
{delta.value}
</span>
@@ -69,8 +94,8 @@ export function KpiCard({
</div>
{sparkline ? (
<div className="-mx-5 -mb-5 mt-2 pt-3 pb-4 border-t border-border/40">
<Sparkline values={sparkline} />
<div className="-mx-5 -mb-5 mt-2 pt-3 pb-3 border-t border-border/30">
<Sparkline values={sparkline} stroke={accentColor} />
</div>
) : null}
</div>
+33 -22
View File
@@ -1,27 +1,29 @@
import { Outlet } from "react-router-dom";
import { useIsFetching } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { Sidebar } from "./Sidebar";
import { SkipLink } from "@/components/ui/skip-link";
import { SearchBar } from "./SearchBar";
/**
* App shell. Three columns:
* - Sidebar (240px, fixed)
* - Top bar (sticky, lightweight utility row)
* - Outlet (page content, max-w-[1400px] centered)
*
* The hairline scan element at the very top of the viewport is a 1px
* line that slides an accent bar across the entire width whenever
* the React Query layer has an in-flight request. The intent is to
* surface "we're doing something" without a spinner — a moving line
* reads as a sensor sweep, not a loading state.
*/
export function Layout() {
// useIsFetching() returns the number of in-flight queries and is 0 on
// first load (queries that have not yet fired are not counted). We use it
// to drive a 1px hairline scan element at the very top of the layout.
const isFetching = useIsFetching();
const showScan = isFetching > 0;
return (
<div className="relative min-h-screen z-10">
{/* Skip link — first Tab stop, jumps past the sidebar.
Hidden until focused (see .skip-link in index.css). */}
<SkipLink />
{/*
Scan element — always in the DOM (no layout shift), 1px tall, full
width. pointer-events-none so it never blocks clicks. The inner
bar uses the `scan` keyframe from tailwind.config to slide a 1/3-
width electric-blue bar across the hairline.
*/}
<div
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
style={{ opacity: showScan ? 1 : 0 }}
@@ -38,19 +40,28 @@ export function Layout() {
tabIndex={-1}
className="md:pl-60 outline-none"
>
{/*
Top bar — sits inside <main> so it shares the sidebar's right
column. Sticky to the viewport top so the search chip stays
reachable while the user scrolls long claim lists. The
hairline + horizontal padding mirrors the main content
container below it for visual continuity. The Sidebar's
own header is taller and brand-marked; this is the
lightweight "always-on utility" row.
*/}
<div className="sticky top-0 z-20 mx-auto max-w-[1400px] px-8 h-12 flex items-center justify-end bg-background/70 backdrop-blur-md border-b border-border/40">
<div
className="sticky top-0 z-20 mx-auto max-w-[1400px] px-6 lg:px-8 h-12 flex items-center justify-end gap-3 bg-background/70 backdrop-blur-md border-b border-border/40"
data-print="hide"
>
{/* Tailing pill on the left edge of the top bar — a tiny
global "system is alive" indicator. It lights up whenever
any query is in flight, complementing the hairline scan. */}
<div className="mr-auto flex items-center gap-2 text-[10.5px] mono uppercase tracking-[0.14em] text-muted-foreground/60">
<span className="relative inline-flex h-1.5 w-1.5">
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60",
showScan && "animate-ping"
)}
/>
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
</span>
{showScan ? "Working" : "Idle"}
</div>
<SearchBar />
</div>
<div className="mx-auto max-w-[1400px] px-8 py-10">
<div className="mx-auto max-w-[1400px] px-6 lg:px-8 py-8 lg:py-10">
<Outlet />
</div>
</main>
+40 -18
View File
@@ -90,22 +90,32 @@ export function NewClaimDialog() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
<Button className="gap-1.5">
<Plus className="h-4 w-4" strokeWidth={2} />
New Claim
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Submit a new claim</DialogTitle>
<DialogDescription>
Create an 837P claim. The submission will be queued for the next
clearinghouse cycle.
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="px-6 pt-6 pb-2">
<span className="eyebrow text-muted-foreground">
New submission
</span>
<DialogHeader className="mt-2 space-y-1.5">
<DialogTitle className="display text-2xl">
Submit a new claim
</DialogTitle>
<DialogDescription className="text-[13px]">
Create an 837P claim. The submission will be queued for the
next clearinghouse cycle.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="grid gap-4">
</div>
<form onSubmit={onSubmit} className="grid gap-5 px-6 pb-6">
<div className="grid gap-2">
<Label htmlFor="patientName">Patient</Label>
<Label htmlFor="patientName" className="eyebrow text-muted-foreground">
Patient
</Label>
<Input
id="patientName"
autoFocus
@@ -117,7 +127,9 @@ export function NewClaimDialog() {
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label>Provider (NPI)</Label>
<Label className="eyebrow text-muted-foreground">
Provider (NPI)
</Label>
<Select
value={form.providerNpi}
onValueChange={(v) => update("providerNpi", v)}
@@ -128,26 +140,30 @@ export function NewClaimDialog() {
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} {p.name}
<span className="mono tabular-nums">{p.npi}</span>
<span className="text-muted-foreground ml-2"> {p.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cptCode">CPT</Label>
<Label htmlFor="cptCode" className="eyebrow text-muted-foreground">
CPT
</Label>
<Input
id="cptCode"
placeholder="99213"
value={form.cptCode}
onChange={(e) => update("cptCode", e.target.value)}
className="mono tabular-nums"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label>Payer</Label>
<Label className="eyebrow text-muted-foreground">Payer</Label>
<Select
value={form.payerName}
onValueChange={(v) => update("payerName", v)}
@@ -173,28 +189,34 @@ export function NewClaimDialog() {
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="billedAmount">Billed amount (USD)</Label>
<Label htmlFor="billedAmount" className="eyebrow text-muted-foreground">
Billed amount
</Label>
<Input
id="billedAmount"
inputMode="decimal"
placeholder="0.00"
value={form.billedAmount}
onChange={(e) => update("billedAmount", e.target.value)}
className="mono tabular-nums"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Label htmlFor="notes" className="eyebrow text-muted-foreground">
Notes <span className="opacity-50">(optional)</span>
</Label>
<Textarea
id="notes"
placeholder="Internal notes — not transmitted to payer"
value={form.notes}
onChange={(e) => update("notes", e.target.value)}
className="min-h-[80px]"
/>
</div>
<DialogFooter>
<DialogFooter className="border-t border-border/40 -mx-6 -mb-6 mt-1 px-6 py-4 bg-background/40">
<Button
type="button"
variant="ghost"
@@ -202,7 +224,7 @@ export function NewClaimDialog() {
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
<Button type="submit" disabled={submitting} className="gap-1.5">
{submitting ? "Submitting…" : "Submit claim"}
</Button>
</DialogFooter>
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* The shared page header. Every page renders one — the eyebrow +
* display title + optional subtitle + actions gives every screen a
* consistent top, with the editorial serif headline providing a
* moment of weight in an otherwise utility-heavy UI.
*
* The eyebrow carries an optional live status pill on the right so
* pages with a live-tail (Claims, Remittances, Activity) can show the
* connection state inline with the page title, not in the toolbar.
*/
export function PageHeader({
eyebrow,
title,
subtitle,
actions,
status,
className,
}: {
eyebrow: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
status?: React.ReactNode;
className?: string;
}) {
return (
<header className={cn("flex items-end justify-between gap-6 flex-wrap", className)}>
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2.5">
<span className="inline-block h-px w-6 bg-foreground/20" />
{eyebrow}
</div>
<h1 className="display text-[28px] sm:text-[32px] leading-[1.1] text-foreground">
{title}
</h1>
{subtitle ? (
<p className="text-muted-foreground mt-2 text-[13.5px] leading-relaxed max-w-2xl">
{subtitle}
</p>
) : null}
</div>
<div className="flex items-center gap-3 flex-wrap justify-end">
{status}
{actions}
</div>
</header>
);
}
@@ -85,24 +85,22 @@ function CasRow({ group }: { group: CasGroup }) {
<div
data-testid="cas-row"
data-carc={`${group.group}-${group.reason}`}
className="grid grid-cols-[6rem_1fr_5rem_6rem] items-baseline gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/10 last:border-b-0"
className="grid grid-cols-[6rem_1fr_5rem_6rem] items-baseline gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/10 last:border-b-0 transition-colors hover:bg-[color:var(--m-surface)]/50"
>
<span
className="font-mono text-sm tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-sm tabular-nums text-[color:var(--m-ink-primary)]"
>
{group.group}-{group.reason}
</span>
<span className="text-sm text-[color:var(--m-ink-secondary)]">{group.label}</span>
<span
className="text-right font-mono text-sm tabular-nums text-[color:var(--m-ink-tertiary)]"
className="text-right mono text-sm tabular-nums text-[color:var(--m-ink-tertiary)]"
data-testid="cas-count"
>
×{group.count}
</span>
<span
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="text-right mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
data-testid="cas-amount"
>
{fmt.usdPrecise(group.total)}
@@ -156,17 +154,17 @@ export function CasAdjustmentsPanel({
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls="cas-adjustments-list"
className="flex items-center gap-2 text-left"
className="flex items-center gap-2 text-left group"
data-testid="cas-toggle"
>
{open ? (
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)] group-hover:text-[color:var(--m-ink-primary)] transition-colors" aria-hidden />
) : (
<ChevronRight className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
<ChevronRight className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)] group-hover:text-[color:var(--m-ink-primary)] transition-colors" aria-hidden />
)}
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)] group-hover:text-[color:var(--m-ink-primary)] transition-colors"
>
CAS Adjustments ({groups.length})
</h3>
@@ -177,22 +175,22 @@ export function CasAdjustmentsPanel({
id="cas-adjustments-list"
data-testid="cas-adjustments-list"
className={cn(
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/15",
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/30",
"bg-[color:var(--m-surface)]/40 overflow-hidden"
)}
>
{/* Header row — labels for the 4-column grid */}
<div className="grid grid-cols-[6rem_1fr_5rem_6rem] gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]/60">
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<div className="grid grid-cols-[6rem_1fr_5rem_6rem] gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/20 bg-[color:var(--m-surface)]/60">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
CARC
</span>
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
Reason
</span>
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<span className="text-right eyebrow text-[color:var(--m-ink-tertiary)]">
Hits
</span>
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<span className="text-right eyebrow text-[color:var(--m-ink-tertiary)]">
Amount
</span>
</div>
@@ -226,7 +224,7 @@ function PerLineSection({ perLine }: { perLine: PerLineCasGroup[] }) {
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Per-line Adjustments ({perLine.length})
</h3>
@@ -246,7 +244,7 @@ function PerLineRow({ group }: { group: PerLineCasGroup }) {
<div
data-testid="per-line-cas-row"
data-line-number={group.slp.lineNumber}
className="flex flex-col gap-1 px-3 py-2 rounded-md border border-[color:var(--m-border-heavy)]/10 bg-[color:var(--m-surface)]/40"
className="flex flex-col gap-1 px-3 py-2 rounded-md border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]/40 transition-colors hover:border-[color:var(--m-border-heavy)]/30"
>
<div className="flex items-baseline justify-between font-mono text-sm">
<span className="text-[color:var(--m-ink-primary)]">
@@ -256,7 +254,7 @@ function PerLineRow({ group }: { group: PerLineCasGroup }) {
</span>
</span>
<span
className="tabular-nums text-[color:var(--m-ink-secondary)]"
className="tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
data-testid="per-line-cas-total"
>
{fmt.usdPrecise(total)}
@@ -270,7 +268,8 @@ function PerLineRow({ group }: { group: PerLineCasGroup }) {
data-testid="per-line-cas-entry"
data-carc={`${a.groupCode}-${a.reasonCode}`}
>
{a.groupCode}-{a.reasonCode} · {fmt.usdPrecise(Number(a.amount))}
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
</li>
))}
</ul>
@@ -296,7 +295,7 @@ function ClaimLevelSection({
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Claim-level adjustments ({adjustments.length})
</h3>
@@ -306,12 +305,12 @@ function ClaimLevelSection({
key={a.id}
data-testid="claim-level-cas-entry"
data-carc={`${a.groupCode}-${a.reasonCode}`}
className="flex justify-between px-3 py-1 rounded-md bg-[color:var(--m-surface)]/40"
className="flex justify-between px-3 py-1.5 rounded-md bg-[color:var(--m-surface)]/40 hover:bg-[color:var(--m-surface)]/70 transition-colors"
>
<span className="text-[color:var(--m-ink-primary)]">
{a.groupCode}-{a.reasonCode}
</span>
<span className="tabular-nums text-[color:var(--m-ink-secondary)]">
<span className="tabular-nums text-[color:var(--m-ink-primary)] font-semibold">
{fmt.usdPrecise(Number(a.amount))}
</span>
</li>
@@ -79,7 +79,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
<section className="flex flex-col gap-3 px-6 py-4" data-testid="claim-payments-table-section">
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Claim Payments (1)
</h3>
@@ -95,7 +95,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
</TableRow>
</TableHeader>
<TableBody>
<TableRow data-testid="claim-payments-row">
<TableRow data-testid="claim-payments-row" className="row-hover">
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
{claimLabel}
</TableCell>
@@ -103,19 +103,19 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
{remit.status}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{hasCharge ? fmt.usdPrecise(remit.totalCharge as number) : "—"}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
className="text-right font-mono text-base tabular-nums text-[color:var(--m-success)] font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(remit.paidAmount)}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
className="text-right font-mono text-base tabular-nums text-[color:var(--m-warning)] font-semibold"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{Math.abs(remit.adjustmentAmount) > 0
@@ -128,7 +128,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
{summary.length > 0 ? (
<div className="flex flex-col gap-2 pt-2" data-testid="claim-payments-carc-summary">
<h4 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
<h4 className="eyebrow text-[color:var(--m-ink-tertiary)]">
CARC breakdown
</h4>
<Table>
@@ -146,6 +146,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
key={carcKey(row.group, row.reason)}
data-testid="carc-row"
data-carc={carcKey(row.group, row.reason)}
className="row-hover"
>
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
{carcKey(row.group, row.reason)}
@@ -1,4 +1,5 @@
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { RemitDetail } from "@/hooks/useRemitDetail";
type FinancialSummaryCardProps = {
@@ -27,16 +28,15 @@ function SummaryRow({
data-testid={testId}
className="flex items-baseline justify-between gap-3 py-2"
>
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
{label}
</span>
<span
className={
mono
? "font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
? "mono text-sm tabular-nums text-[color:var(--m-ink-primary)]"
: "text-sm text-[color:var(--m-ink-primary)]"
}
style={mono ? { fontFamily: "var(--m-font-mono)" } : undefined}
>
{value}
</span>
@@ -78,45 +78,56 @@ export function FinancialSummaryCard({ remit }: FinancialSummaryCardProps) {
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Financial Summary
</h3>
<div
data-testid="financial-summary-inner"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] p-4"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4"
>
{/* Headline totals — big mono digits, side by side */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Headline totals — big display digits, side by side */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div
data-testid="summary-paid"
className="flex flex-col gap-1 rounded-md bg-[color:var(--m-surface)]/40 px-3 py-2"
className="flex flex-col gap-1.5 rounded-md border border-[color:var(--m-success)]/30 bg-[hsl(var(--success)/0.08)] px-3 py-2.5"
>
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-success)]">
Paid amount
</span>
<span
className="font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="display text-2xl tabular-nums text-[color:var(--m-success)]"
>
{fmt.usdPrecise(remit.paidAmount)}
</span>
</div>
<div
data-testid="summary-adjustment"
className="flex flex-col gap-1 rounded-md bg-[color:var(--m-surface)]/40 px-3 py-2"
className={cn(
"flex flex-col gap-1.5 rounded-md border px-3 py-2.5",
hasAdjustment
? "border-[color:var(--m-warning)]/40 bg-[hsl(var(--warning)/0.10)]"
: "border-[color:var(--m-border-heavy)]/20 bg-[color:var(--m-surface)]/40"
)}
>
<span
className={cn(
"eyebrow",
hasAdjustment
? "text-[color:var(--m-warning)]"
: "text-[color:var(--m-ink-tertiary)]"
)}
>
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Adjustment amount
</span>
<span
className={
className={cn(
"display text-2xl tabular-nums",
hasAdjustment
? "font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
: "font-mono text-2xl tabular-nums text-[color:var(--m-ink-tertiary)]"
}
style={{ fontFamily: "var(--m-font-mono)" }}
? "text-[color:var(--m-warning)]"
: "text-[color:var(--m-ink-tertiary)]"
)}
>
{hasAdjustment ? fmt.usdPrecise(remit.adjustmentAmount) : "—"}
</span>
@@ -124,7 +135,7 @@ export function FinancialSummaryCard({ remit }: FinancialSummaryCardProps) {
</div>
{/* Supporting rows: method, payment date, check / trace number */}
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/10">
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
<SummaryRow
testId="summary-method"
label="Payment method"
+7 -8
View File
@@ -38,7 +38,7 @@ function isEmptyAddress(addr: AddressLike): boolean {
function AddressBlock({ address }: { address: NonNullable<AddressLike> }) {
return (
<div
className="text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
data-testid="party-address"
>
{address.line1 ? <div>{address.line1}</div> : null}
@@ -75,17 +75,16 @@ function PartyCard({
return (
<div
data-testid={testId}
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] px-4 py-3"
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
>
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
{label}
</span>
<div className="text-base font-medium text-[color:var(--m-ink-primary)]">
<div className="display text-lg text-[color:var(--m-ink-primary)]">
{name}
</div>
<div
className="font-mono text-[13px] text-[color:var(--m-ink-secondary)] tabular-nums"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
>
{identity}
</div>
@@ -154,14 +153,14 @@ export function PartiesGrid({ parties, fallbackName }: PartiesGridProps) {
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
className="eyebrow text-[color:var(--m-ink-tertiary)]"
>
Parties
</h3>
<div
data-testid="parties-grid-inner"
className="grid grid-cols-1 md:grid-cols-2 gap-4"
className="grid grid-cols-1 md:grid-cols-2 gap-3"
>
<PartyCard
testId="party-payer"
+2 -2
View File
@@ -131,7 +131,7 @@ export function RemitDrawer({
// positioning override. The Dialog primitive's default
// centered layout is suppressed so the drawer reads as a
// side-panel, not a modal.
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)] bg-[color:var(--m-surface)] p-0"
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
data-testid="remit-drawer"
aria-describedby={undefined}
>
@@ -151,7 +151,7 @@ export function RemitDrawer({
data-testid="remit-drawer-content"
>
<RemitDrawerHeader remit={data} onClose={onClose} />
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/12">
<FinancialSummaryCard remit={data} />
<PartiesGrid parties={data} fallbackName={data.payerName} />
<ClaimPaymentsTable remit={data} />
@@ -50,11 +50,11 @@ export function RemitDrawerError({
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-error)]">
<span className="eyebrow text-[color:var(--m-error)]">
{eyebrow}
</span>
</div>
<p className="text-sm text-[color:var(--m-ink-secondary)]">{message}</p>
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
@@ -52,20 +52,19 @@ export function RemitDrawerHeader({ remit, onClose }: RemitDrawerHeaderProps) {
<header
className={cn(
"flex items-start justify-between gap-4 px-6 py-5",
"border-b border-[color:var(--m-border-heavy)]",
"border-b border-[color:var(--m-border-heavy)]/40",
"bg-[color:var(--m-surface)]"
)}
data-testid="remit-drawer-header"
>
{/* Left: eyebrow + mono remit ID + raw link */}
<div className="flex min-w-0 flex-col gap-1">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
Remittance
</span>
<span
data-testid="header-id"
className="font-mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
>
{remit.id}
</span>
@@ -74,7 +73,7 @@ export function RemitDrawerHeader({ remit, onClose }: RemitDrawerHeaderProps) {
target="_blank"
rel="noreferrer"
data-testid="header-raw-link"
className="mt-1 inline-flex w-fit items-center gap-1 text-[11px] text-[color:var(--m-ink-tertiary)] hover:text-[color:var(--m-ink-secondary)]"
className="mt-1 inline-flex w-fit items-center gap-1 text-[11px] text-[color:var(--m-ink-tertiary)] hover:text-[color:var(--m-ink-secondary)] transition-colors"
>
View raw 835
<ExternalLink className="h-3 w-3" strokeWidth={1.75} aria-hidden />
@@ -93,8 +92,7 @@ export function RemitDrawerHeader({ remit, onClose }: RemitDrawerHeaderProps) {
</Badge>
<span
data-testid="header-paid"
className="font-mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
>
{fmt.usdPrecise(remit.paidAmount)}
</span>
+20 -12
View File
@@ -74,12 +74,12 @@ function SearchTrigger({ onOpen }: { onOpen: () => void }) {
aria-label="Open global search (Cmd or Ctrl + K)"
className={cn(
"group inline-flex items-center gap-2.5 h-8 pl-2.5 pr-2 rounded-md",
"border border-border/60 bg-background/40 hover:bg-background/70",
"border border-border/60 bg-background/40 hover:bg-background/70 hover:border-border/80",
"text-[12px] text-muted-foreground hover:text-foreground",
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
"transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
)}
>
<Search className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
<Search className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground transition-colors" strokeWidth={1.75} aria-hidden />
<span className="font-medium tracking-tight">Search</span>
<span className="ml-2 inline-flex items-center gap-0.5">
<kbd className="kbd">{MOD_LABEL}</kbd>
@@ -217,7 +217,7 @@ export function SearchBar() {
className={cn(
"max-w-2xl p-0 gap-0 overflow-hidden",
"bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]",
"border border-[color:var(--m-border-heavy)]/20"
"border border-[color:var(--m-border-heavy)]/30 shadow-2xl"
)}
aria-describedby={undefined}
data-testid="search-dialog"
@@ -228,9 +228,9 @@ export function SearchBar() {
* tabbable element on open). `pl-9` makes room for the
* magnifier icon on the left.
*/}
<div className="relative border-b border-border/40">
<div className="relative border-b border-[color:var(--m-border-heavy)]/20">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[color:var(--m-ink-tertiary)] pointer-events-none"
strokeWidth={1.75}
aria-hidden
/>
@@ -249,9 +249,10 @@ export function SearchBar() {
onKeyDown={onInputKeyDown}
data-testid="search-input"
className={cn(
"h-12 pl-9 pr-3 border-0 bg-transparent rounded-none",
"text-[14px] font-mono tracking-tight",
"shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
"h-12 pl-10 pr-3 border-0 bg-transparent rounded-none",
"text-[14px] mono tracking-tight",
"shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:bg-transparent",
"text-[color:var(--m-ink-primary)] placeholder:text-[color:var(--m-ink-tertiary)]"
)}
/>
</div>
@@ -270,7 +271,7 @@ export function SearchBar() {
</div>
{/* Footer hint — shows the keyboard contract. */}
<div className="flex items-center justify-between gap-3 px-3 py-2 border-t border-border/40 text-[10.5px] font-mono uppercase tracking-[0.12em] text-muted-foreground">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-t border-[color:var(--m-border-heavy)]/20 bg-[color:var(--m-surface)]/80 text-[10px] mono uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1">
<kbd className="kbd"></kbd>
@@ -287,9 +288,16 @@ export function SearchBar() {
</span>
</div>
{isLoading ? (
<span className="text-muted-foreground/70">loading</span>
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber, hsl(var(--accent)))" }}
/>
loading
</span>
) : (
<span className="text-muted-foreground/70">
<span className="tabular-nums">
{flatTotal} {flatTotal === 1 ? "result" : "results"}
</span>
)}
+20 -14
View File
@@ -59,35 +59,35 @@ function ResultRow({
}}
onMouseEnter={() => onHover(index)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
"group w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
"focus:outline-none",
selected
? "bg-accent/15 ring-1 ring-inset ring-accent/50"
: "hover:bg-muted/50"
: "hover:bg-muted/40"
)}
>
<span
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors",
selected
? "bg-accent/20 text-accent"
: "bg-muted text-muted-foreground"
? "bg-accent/25 text-accent"
: "bg-muted text-muted-foreground group-hover:bg-muted/70"
)}
aria-hidden
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium truncate">
<span className="block text-[13px] font-medium truncate text-foreground">
{result.title}
</span>
<span className="block text-[11px] font-mono text-muted-foreground truncate">
<span className="block text-[11px] mono text-muted-foreground truncate">
{result.subtitle}
</span>
</span>
<span
className={cn(
"shrink-0 px-1.5 py-0.5 rounded text-[10px] font-mono uppercase tracking-[0.08em]",
"shrink-0 px-1.5 py-0.5 rounded text-[10px] mono uppercase tracking-[0.14em] font-medium",
"bg-muted/70 text-muted-foreground"
)}
>
@@ -100,7 +100,8 @@ function ResultRow({
/** Section header — small caps label above each group. */
function GroupHeader({ label }: { label: string }) {
return (
<div className="px-3 pt-3 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<div className="px-4 pt-3 pb-1.5 flex items-center gap-2 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<span aria-hidden className="inline-block h-px w-3 bg-border" />
{label}
</div>
);
@@ -109,11 +110,16 @@ function GroupHeader({ label }: { label: string }) {
/** Render the "no matches" state. */
function EmptyState({ query }: { query: string }) {
return (
<div className="px-4 py-10 text-center text-muted-foreground">
<div className="text-[12px] font-mono">
{query
? <>No matches for <span className="text-foreground">{query}</span></>
: "Type to search across claims, remittances, and activity."}
<div className="px-4 py-12 text-center text-muted-foreground">
<div className="mono text-[12px]">
{query ? (
<>
No matches for{" "}
<span className="text-foreground font-semibold">&ldquo;{query}&rdquo;</span>
</>
) : (
"Type to search across claims, remittances, and activity."
)}
</div>
</div>
);
+110 -48
View File
@@ -24,20 +24,45 @@ const nav = [
{ to: "/activity", label: "Activity Log", icon: Activity },
];
/**
* Sidebar. Three sections under the brand mark:
* - WORKSPACE — the day-to-day surface
* - WORKFLOWS — triage & reconciliation lanes
* - REFERENCE — long-tail batches & acks
*
* The active state uses a 2px left accent + an inset tint + a
* subtle inner ring so the selected item reads instantly. Counts
* (e.g. unmatched on Reconciliation) live as mono digits aligned to
* the right edge so the eye can scan state at a glance.
*/
export function Sidebar() {
// Surface the unmatched-bucket size on the Reconciliation nav entry so the
// operator notices when manual reconciliation is needed.
const { unmatched } = useReconciliation();
const unmatchedCount =
(unmatched.data?.claims.length ?? 0) +
(unmatched.data?.remittances.length ?? 0);
return (
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/60 backdrop-blur-xl">
<div className="flex h-16 items-center px-6 border-b border-border/60">
<div className="flex items-center gap-2.5">
<div className="h-7 w-7 rounded-md bg-accent flex items-center justify-center shadow-sm">
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5 text-white" fill="none">
<aside
className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/80 backdrop-blur-xl"
data-print="hide"
>
{/* Brand mark — the editorial display face carries the wordmark
while a small monospaced slug below keeps the brand
unmistakably "machine". */}
<div className="flex h-16 items-center px-5 border-b border-sidebar-border/80">
<NavLink
to="/"
end
className="flex items-center gap-2.5 group"
aria-label="Cyclone — go to dashboard"
>
<div className="relative h-7 w-7 rounded-md bg-accent flex items-center justify-center shadow-[0_0_0_1px_hsl(var(--accent)/0.4),0_0_18px_-2px_hsl(var(--accent)/0.5)]">
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5 text-white"
fill="none"
aria-hidden
>
<path
d="M6 8h8a3 3 0 0 1 0 6h-5a3 3 0 0 0 0 6h9"
stroke="currentColor"
@@ -48,59 +73,47 @@ export function Sidebar() {
</svg>
</div>
<div className="leading-none">
<div className="text-[16px] font-semibold tracking-tight text-foreground">
<div className="display text-[19px] tracking-tight text-foreground -mt-px">
Cyclone
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-muted-foreground mt-0.5">
<div className="mono text-[9.5px] uppercase tracking-[0.18em] text-muted-foreground mt-1">
CuNtx · Claims
</div>
</div>
</div>
</NavLink>
</div>
<nav className="flex-1 px-3 py-6">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground px-3 mb-2">
Workspace
</div>
<ul className="space-y-0.5">
<nav className="flex-1 overflow-y-auto px-3 py-5">
<SectionLabel>Workspace</SectionLabel>
<ul className="space-y-0.5 mb-5">
{nav.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<item.icon className="h-4 w-4" strokeWidth={1.75} />
<span>{item.label}</span>
</NavLink>
<NavItem item={item} />
</li>
))}
</ul>
<SectionLabel>Workflows</SectionLabel>
<ul className="space-y-0.5 mb-5">
<li>
<NavLink
to="/reconciliation"
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<GitMerge className="h-4 w-4" strokeWidth={1.5} />
<GitMerge className="h-3.5 w-3.5" strokeWidth={1.5} />
<span>Reconciliation</span>
{unmatchedCount > 0 && (
<span className="ml-auto text-[10px] font-mono tabular-nums text-warning">
{unmatchedCount > 0 ? (
<span className="ml-auto mono text-[10px] text-signal">
{unmatchedCount > 99 ? "99+" : unmatchedCount}
</span>
)}
) : null}
</NavLink>
</li>
<li>
@@ -108,30 +121,34 @@ export function Sidebar() {
to="/inbox"
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<InboxIcon className="h-4 w-4" strokeWidth={1.5} />
<InboxIcon className="h-3.5 w-3.5" strokeWidth={1.5} />
<span>Inbox</span>
</NavLink>
</li>
</ul>
<SectionLabel>Reference</SectionLabel>
<ul className="space-y-0.5">
<li>
<NavLink
to="/acks"
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<CheckCircle2 className="h-4 w-4" strokeWidth={1.5} />
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.5} />
<span>999 ACKs</span>
</NavLink>
</li>
@@ -140,14 +157,14 @@ export function Sidebar() {
to="/batches"
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<Layers className="h-4 w-4" strokeWidth={1.5} />
<Layers className="h-3.5 w-3.5" strokeWidth={1.5} />
<span>Batches</span>
</NavLink>
</li>
@@ -156,31 +173,76 @@ export function Sidebar() {
to="/batch-diff"
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />
<GitCompareArrows className="h-3.5 w-3.5" strokeWidth={1.5} />
<span>Batch diff</span>
</NavLink>
</li>
</ul>
</nav>
<div className="px-3 py-4 border-t border-border/60">
{/* Operator card — always at the bottom. Ringed, mono id, the
account role under the name. */}
<div className="px-3 py-4 border-t border-sidebar-border/80">
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
<div className="h-8 w-8 rounded-full bg-accent flex items-center justify-center text-[11px] font-semibold text-white ring-1 ring-inset ring-white/10">
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono">
JK
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
</div>
<div className="leading-tight min-w-0">
<div className="text-[13px] font-medium truncate">Jordan K.</div>
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
Administrator
</div>
<div className="leading-tight">
<div className="text-sm font-medium">Jordan K.</div>
<div className="text-[11px] text-muted-foreground">Administrator</div>
</div>
</div>
</div>
</aside>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
{children}
</div>
);
}
function NavItem({
item,
}: {
item: {
to: string;
label: string;
// Lucide icons accept `strokeWidth` as a wider union (string | number | null | undefined);
// we declare it as `number | string` so any Lucide icon can be assigned
// here without the prop-types assignability complaint from TS.
icon: React.ComponentType<{ className?: string; strokeWidth?: number | string }>;
end?: boolean;
};
}) {
return (
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<item.icon className="h-3.5 w-3.5" strokeWidth={1.5} />
<span>{item.label}</span>
</NavLink>
);
}
+20 -8
View File
@@ -1,14 +1,20 @@
interface SparklineProps {
values: number[];
className?: string;
stroke?: string;
}
/**
* Minimal SVG sparkline. Normalises to its own range, draws a stroked line
* with a soft fill, and marks the last point. Stretch-friendly via
* preserveAspectRatio="none" + non-scaling-stroke.
* Minimal SVG sparkline. Normalises to its own range, draws a stroked
* line with a soft fill, and marks the last point. Uses
* preserveAspectRatio="none" + non-scaling-stroke so the chart stretches
* with its container without distorting the stroke.
*/
export function Sparkline({ values, className }: SparklineProps) {
export function Sparkline({
values,
className,
stroke = "hsl(var(--accent))",
}: SparklineProps) {
if (!values || values.length < 2) return null;
const w = 100;
const h = 28;
@@ -37,11 +43,17 @@ export function Sparkline({ values, className }: SparklineProps) {
height="36"
aria-hidden="true"
>
<path d={area} fill="hsl(var(--accent) / 0.13)" />
<defs>
<linearGradient id="sparkline-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.18" />
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#sparkline-fill)" />
<path
d={line}
fill="none"
stroke="hsl(var(--accent))"
stroke={stroke}
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
@@ -50,8 +62,8 @@ export function Sparkline({ values, className }: SparklineProps) {
<circle
cx={last[0]}
cy={last[1]}
r="2"
fill="hsl(var(--accent))"
r="2.2"
fill={stroke}
vectorEffect="non-scaling-stroke"
/>
</svg>
+25 -9
View File
@@ -8,7 +8,7 @@ const claimStyles: Record<ClaimStatus, { dot: string; label: string }> = {
draft: { dot: "bg-muted-foreground", label: "Draft" },
submitted: { dot: "bg-[hsl(var(--accent))]", label: "Submitted" },
accepted: { dot: "bg-[hsl(var(--success))]", label: "Accepted" },
denied: { dot: "bg-destructive", label: "Denied" },
denied: { dot: "bg-[hsl(var(--destructive))]", label: "Denied" },
paid: { dot: "bg-[hsl(var(--success))]", label: "Paid" },
pending: { dot: "bg-[hsl(var(--warning))]", label: "Pending" },
};
@@ -43,11 +43,15 @@ export function StatusBadge({
<Badge
variant="outline"
className={cn(
"uppercase tracking-wider text-[10px]",
"bg-muted text-muted-foreground border-border",
"gap-1.5 uppercase tracking-[0.14em] text-[10.5px] font-medium",
"bg-muted/40 text-muted-foreground border-border/60",
className,
)}
>
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70"
/>
{status}
</Badge>
);
@@ -62,9 +66,15 @@ export function ClaimStatusBadge({ status }: { status: ClaimStatus }) {
}
const s = claimStyles[status];
return (
<Badge variant="outline" className="bg-transparent border-border/60">
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
<span className="font-normal">{s.label}</span>
<Badge
variant="outline"
className="gap-1.5 bg-transparent border-border/60 uppercase tracking-[0.14em] text-[10.5px]"
>
<span
aria-hidden
className={cn("inline-block h-1.5 w-1.5 rounded-full", s.dot)}
/>
<span className="font-medium">{s.label}</span>
</Badge>
);
}
@@ -75,9 +85,15 @@ export function RemitStatusBadge({ status }: { status: RemittanceStatus }) {
}
const s = remitStyles[status];
return (
<Badge variant="outline" className="bg-transparent border-border/60">
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
<span className="font-normal">{s.label}</span>
<Badge
variant="outline"
className="gap-1.5 bg-transparent border-border/60 uppercase tracking-[0.14em] text-[10.5px]"
>
<span
aria-hidden
className={cn("inline-block h-1.5 w-1.5 rounded-full", s.dot)}
/>
<span className="font-medium">{s.label}</span>
</Badge>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
export type ConnectionStatus =
| "live"
| "connecting"
| "reconnecting"
| "closed"
| "stalled"
| "error";
const STATUS_TONE: Record<
ConnectionStatus,
{ dot: string; ring: string; label: string }
> = {
live: {
dot: "bg-[hsl(var(--success))]",
ring: "ring-[hsl(var(--success))]/40",
label: "Live",
},
connecting: {
dot: "bg-[hsl(var(--signal))]",
ring: "ring-[hsl(var(--signal))]/40",
label: "Connecting",
},
reconnecting: {
dot: "bg-[hsl(var(--signal))]",
ring: "ring-[hsl(var(--signal))]/40",
label: "Reconnecting",
},
closed: {
dot: "bg-muted-foreground",
ring: "ring-muted-foreground/30",
label: "Closed",
},
stalled: {
dot: "bg-destructive",
ring: "ring-destructive/40",
label: "Stalled",
},
error: {
dot: "bg-destructive",
ring: "ring-destructive/40",
label: "Error",
},
};
/**
* Refined status pill with an animated dot. The dot pulses for any
* non-terminal status and stays solid for stable states. Sits inline
* with other header chrome and reads as a one-word status, not a
* multi-line status panel.
*/
export function StatusPill({
status,
label,
className,
}: {
status: ConnectionStatus;
label?: string;
className?: string;
}) {
const tone = STATUS_TONE[status];
const isPulsing =
status === "live" ||
status === "connecting" ||
status === "reconnecting";
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-card/60 px-2.5 py-1 mono text-[10.5px] uppercase tracking-[0.14em]",
className
)}
>
<span
className={cn(
"relative inline-flex h-1.5 w-1.5 rounded-full",
tone.dot,
"ring-4",
tone.ring
)}
>
{isPulsing ? (
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping",
tone.dot
)}
/>
) : null}
</span>
<span className="text-foreground/80">{label ?? tone.label}</span>
</span>
);
}
/**
* Re-tick every second so a "Last event: 12s ago" subtitle stays
* current without a parent re-render. Mount-once interval.
*/
export function useTickingNow(intervalMs = 1000): number {
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
export function formatAge(seconds: number): string {
if (seconds < 0) return "0s";
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
+24 -63
View File
@@ -3,23 +3,20 @@
//
// Tiny presentational component used in the toolbar of `Claims`,
// `Remittances`, and `ActivityLog` to surface the connection status of
// `useTailStream`. Renders the existing `<Badge>` (variants from
// `src/components/ui/badge.tsx`) plus a "Last event: 12s ago" subtitle
// that re-ticks every second, plus a "↻ Reconnect" button that only
// appears when the connection has visibly failed (`stalled` or `error`).
//
// Variant mapping (per spec):
// live → success
// connecting → warning
// reconnecting → warning
// closed → destructive
// stalled → destructive
// error → destructive
// `useTailStream`. Renders a status dot + label + "Last event: 12s ago"
// subtitle that re-ticks every second, plus a "↻ Reconnect" button
// that only appears when the connection has visibly failed (`stalled`
// or `error`).
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
StatusPill,
formatAge,
useTickingNow,
type ConnectionStatus,
} from "@/components/StatusPill";
import type { TailStatus } from "@/hooks/useTailStream";
export interface TailStatusPillProps {
@@ -29,55 +26,20 @@ export interface TailStatusPillProps {
}
const STATUS_LABEL: Record<TailStatus, string> = {
connecting: "Connecting",
connecting: "Connecting",
live: "Live",
reconnecting: "Reconnecting",
reconnecting: "Reconnecting",
closed: "Closed",
stalled: "Stalled",
error: "Error",
};
type BadgeVariant = "success" | "warning" | "destructive";
const STATUS_VARIANT: Record<TailStatus, BadgeVariant> = {
live: "success",
connecting: "warning",
reconnecting: "warning",
closed: "destructive",
stalled: "destructive",
error: "destructive",
};
/**
* Render a duration in seconds as a compact human label. We deliberately
* keep the units small — under a minute is the common case for a healthy
* tail (the stall detector fires at 30s, so anything beyond is a real
* outage the user will want to investigate).
*/
function formatAge(seconds: number): string {
if (seconds < 0) return "0s";
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
export function TailStatusPill({
status,
lastEventAt,
onReconnect,
}: TailStatusPillProps): React.JSX.Element {
// Re-tick every second so the "Last event: 12s ago" subtitle stays
// current without the parent having to re-render. We mount the
// interval only once per pill instance.
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const now = useTickingNow(1000);
const ageSeconds = lastEventAt
? Math.max(0, Math.floor((now - lastEventAt.getTime()) / 1000))
: null;
@@ -87,26 +49,24 @@ export function TailStatusPill({
? "Last event: never"
: `Last event: ${formatAge(ageSeconds)} ago`;
// Only show the manual reconnect affordance when the connection has
// visibly failed. `closed` is reserved for the post-unmount state and
// is intentionally not actionable — the component is about to vanish.
const showReconnect = status === "stalled" || status === "error";
return (
<div
className="inline-flex items-center gap-2"
className="inline-flex items-center gap-2.5"
data-testid="tail-status-pill"
>
<Badge variant={STATUS_VARIANT[status]} aria-label={`tail status: ${status}`}>
{STATUS_LABEL[status]}
</Badge>
<StatusPill
status={status as ConnectionStatus}
label={STATUS_LABEL[status]}
/>
<span
className="text-xs text-muted-foreground"
className="mono text-[10.5px] text-muted-foreground hidden sm:inline"
data-testid="tail-status-pill-subtitle"
>
{subtitle}
</span>
{showReconnect && (
{showReconnect ? (
<Button
type="button"
variant="outline"
@@ -114,9 +74,10 @@ export function TailStatusPill({
onClick={onReconnect}
data-testid="tail-status-pill-reconnect"
>
Reconnect
<RotateCw className="h-3 w-3" strokeWidth={2} />
Reconnect
</Button>
)}
) : null}
</div>
);
}
+26 -9
View File
@@ -14,23 +14,40 @@ export function BulkBar({
if (count === 0) return null;
return (
<div
className="fixed bottom-4 left-1/2 px-4 py-2 font-mono flex gap-3 z-20"
className="fixed bottom-6 left-1/2 px-3 py-2.5 mono flex items-center gap-2 z-20 rounded-md"
style={{
transform: "translateX(-50%)",
background: "var(--tt-bg-elev)",
border: "1px solid var(--tt-amber)",
color: "var(--tt-ink)",
fontSize: 11,
boxShadow:
"0 0 0 1px hsl(36 88% 56% / 0.15), 0 12px 40px -8px hsl(0 0% 0% / 0.6), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
}}
>
<span className="tabular-nums" style={{ color: "var(--tt-amber)" }}>
{count} selected
<span
className="tabular-nums flex items-center gap-1.5 px-2 py-0.5 rounded-sm"
style={{
color: "var(--tt-bg)",
background: "var(--tt-amber)",
fontWeight: 700,
fontSize: 11,
letterSpacing: "0.04em",
}}
>
{count}
</span>
<span style={{ color: "var(--tt-amber)", letterSpacing: "0.12em", textTransform: "uppercase" }}>
selected
</span>
<span className="mx-1 h-4 w-px" style={{ background: "var(--tt-muted)" }} aria-hidden />
{lane === "rejected" && (
<button
onClick={onResubmit}
className="hover:underline"
style={{ color: "var(--tt-ink)" }}
className="px-2.5 py-1 rounded-sm uppercase tracking-[0.12em] transition-colors hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
style={{ color: "var(--tt-ink)", fontWeight: 600 }}
>
Re-submit ({count})
</button>
@@ -38,16 +55,16 @@ export function BulkBar({
{lane === "candidates" && (
<button
onClick={onDismiss}
className="hover:underline"
style={{ color: "var(--tt-ink)" }}
className="px-2.5 py-1 rounded-sm uppercase tracking-[0.12em] transition-colors hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
style={{ color: "var(--tt-ink)", fontWeight: 600 }}
>
Dismiss ({count})
</button>
)}
<button
onClick={onExport}
className="hover:underline"
style={{ color: "var(--tt-ink)" }}
className="px-2.5 py-1 rounded-sm uppercase tracking-[0.12em] transition-colors hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
style={{ color: "var(--tt-ink)", fontWeight: 600 }}
>
Export CSV ({count})
</button>
+61 -16
View File
@@ -17,38 +17,83 @@ export function CandidateBreakdown({
tier: ScoreTier;
}) {
return (
<div className="font-mono" style={{ fontSize: 11 }}>
<div className="flex items-baseline justify-between mb-2">
<div className="mono" style={{ fontSize: 11 }}>
<div className="flex items-baseline justify-between mb-3">
<span
className="uppercase"
style={{ color: "var(--tt-ink-dim)", letterSpacing: "0.12em" }}
style={{
color: "var(--tt-ink-dim)",
letterSpacing: "0.18em",
fontSize: 10,
fontWeight: 600,
}}
>
Score
Match score
</span>
<span
className="tabular-nums"
style={{ color: "var(--tt-amber)", fontSize: 24 }}
className="display tabular-nums"
style={{
color: "var(--tt-amber)",
fontSize: 28,
lineHeight: 1,
letterSpacing: "-0.02em",
}}
>
{score}
</span>
</div>
<ul className="space-y-1">
{FIELDS.map((f) => (
<li key={f.key} className="flex justify-between">
<ul className="space-y-1.5">
{FIELDS.map((f) => {
const v = breakdown[f.key];
const score = Math.round(v * f.weight);
const pct = Math.max(0.04, v);
return (
<li key={f.key} className="flex flex-col gap-1">
<div className="flex items-baseline justify-between">
<span style={{ color: "var(--tt-ink)" }}>{f.label}</span>
<span
className="tabular-nums"
style={{ color: "var(--tt-ink-dim)" }}
>
{Math.round(breakdown[f.key] * f.weight)} / {f.weight}
<span style={{ color: "var(--tt-amber)" }}>{score}</span>
<span className="opacity-50"> / {f.weight}</span>
</span>
</li>
))}
</ul>
<p
className="mt-3 uppercase"
style={{ color: "var(--tt-ink-dim)", letterSpacing: "0.12em", fontSize: 10 }}
</div>
<div
aria-hidden
className="h-[3px] rounded-full overflow-hidden"
style={{ background: "var(--tt-muted)" }}
>
<div
className="h-full rounded-full"
style={{
width: `${pct * 100}%`,
background:
"linear-gradient(90deg, var(--tt-amber) 0%, var(--tt-amber) 100%)",
boxShadow: "0 0 4px -1px var(--tt-amber)",
}}
/>
</div>
</li>
);
})}
</ul>
<p
className="mt-4 inline-flex items-center gap-1.5 uppercase"
style={{
color: "var(--tt-amber)",
letterSpacing: "0.18em",
fontSize: 9,
fontWeight: 700,
}}
>
<span
aria-hidden
className="inline-block h-1 w-1 rounded-full"
style={{ background: "var(--tt-amber)" }}
/>
{tier}
</p>
</div>
+51 -17
View File
@@ -7,6 +7,10 @@ export function InboxHeader({
}) {
const now = new Date();
const day = now.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase();
const date = now.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
});
const time = now.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
@@ -15,35 +19,65 @@ export function InboxHeader({
return (
<header
className="sticky top-0 z-10 px-6 py-4"
className="sticky top-0 z-10 px-6 pt-6 pb-4"
style={{
background: "var(--tt-bg)",
borderBottom: "1px solid var(--tt-bg-elev)",
}}
>
<div className="flex items-baseline justify-between gap-4">
<div className="flex items-baseline gap-4">
<h1
className="tracking-tight"
className="display tracking-tight"
style={{
color: "var(--tt-ink)",
fontFamily: "'Inter Tight', system-ui, sans-serif",
fontWeight: 700,
fontSize: 22,
fontSize: 30,
lineHeight: 1,
}}
>
INBOX{" "}
<span
className="font-mono"
style={{ color: "var(--tt-amber)", fontSize: 16 }}
>
· {day} {time}
</span>
Inbox
</h1>
<p
className="font-mono mt-1"
style={{ color: "var(--tt-ink-dim)", fontSize: 11, letterSpacing: "0.05em" }}
<span
className="mono uppercase"
style={{
color: "var(--tt-amber)",
fontSize: 11,
letterSpacing: "0.18em",
}}
>
<span style={{ color: "var(--tt-amber)" }}>{needEyesCount}</span> items need eyes ·{" "}
<span style={{ color: "var(--tt-ink)" }}>{doneTodayCount}</span> done today
· {day} {date}
</span>
</div>
<span
className="mono tabular-nums"
style={{
color: "var(--tt-ink-dim)",
fontSize: 12,
letterSpacing: "0.05em",
}}
>
{time}
</span>
</div>
<p
className="mono mt-2 flex items-center gap-2"
style={{
color: "var(--tt-ink-dim)",
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber)" }}
aria-hidden
/>
<span style={{ color: "var(--tt-amber)", fontWeight: 600 }}>{needEyesCount}</span>
items need eyes
<span className="opacity-50">·</span>
<span style={{ color: "var(--tt-ink)", fontWeight: 600 }}>{doneTodayCount}</span>
done today
</p>
</header>
);
+31 -19
View File
@@ -1,4 +1,5 @@
import type { InboxClaimRow, InboxCandidateRow, ScoreBreakdown } from "@/lib/inbox-api";
import { cn } from "@/lib/utils";
export type Accent = "oxblood" | "amber" | "ink-blue" | "muted";
@@ -30,19 +31,23 @@ function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) {
if (!breakdown) return null;
const fields: Array<keyof ScoreBreakdown> = ["patient", "date", "amount", "provider"];
return (
<span className="inline-flex items-end gap-0.5 align-middle h-3">
<span className="inline-flex items-end gap-[2px] align-middle h-3.5">
{fields.map((k) => {
const v = breakdown[k] as number;
const h = Math.max(2, Math.round(v * 12));
const h = Math.max(3, Math.round(v * 14));
return (
<span
key={k}
title={`${k}: ${v.toFixed(2)}`}
className="inline-block w-1.5"
style={{
height: `${h}px`,
background: v >= 0.5 ? "var(--tt-amber)" : "var(--tt-muted)",
}}
className={cn(
"inline-block w-[3px] rounded-[1px] transition-all",
v >= 0.7
? "bg-[color:var(--tt-amber)] shadow-[0_0_4px_-1px_var(--tt-amber)]"
: v >= 0.5
? "bg-[color:var(--tt-amber)]/70"
: "bg-[color:var(--tt-muted)]"
)}
style={{ height: `${h}px` }}
/>
);
})}
@@ -67,41 +72,48 @@ export function InboxRow({ row, accent, onClick }: Props) {
return (
<tr
onClick={onClick}
className="cursor-pointer hover:bg-white/[0.03] transition-colors"
style={{ borderBottom: "1px solid var(--tt-bg-elev)" }}
className="cursor-pointer transition-colors group"
>
<td style={{ width: 4, background: ACCENT_VAR[accent], padding: 0 }} />
<td
className="py-2 pl-3 font-mono"
style={{ color: "var(--tt-ink)", fontSize: 12 }}
aria-hidden
style={{
width: 4,
background: ACCENT_VAR[accent],
padding: 0,
boxShadow: `inset 0 0 0 1px ${ACCENT_VAR[accent]}40`,
}}
/>
<td
className="py-2.5 pl-3 mono font-semibold"
style={{ color: "var(--tt-ink)", fontSize: 12, letterSpacing: "-0.005em" }}
>
{id}
</td>
<td
className="py-2 font-mono"
style={{ color: "var(--tt-ink-dim)", fontSize: 11 }}
className="py-2.5 mono"
style={{ color: reason ? "var(--tt-oxblood)" : "var(--tt-amber)", fontSize: 11 }}
>
{reason ?? (topScore != null ? `score ${topScore}` : "")}
</td>
<td
className="py-2 font-mono tabular-nums"
className="py-2.5 mono tabular-nums"
style={{ color: "var(--tt-ink-dim)", fontSize: 11 }}
>
{fmtAgo(rejectedAt)}
</td>
<td
className="py-2 text-right font-mono tabular-nums"
className="py-2.5 text-right mono tabular-nums font-semibold"
style={{ color: "var(--tt-ink)", fontSize: 12 }}
>
{charge}
</td>
<td
className="py-2 pl-4 uppercase tracking-wider"
style={{ color: "var(--tt-ink-dim)", fontSize: 10 }}
className="py-2.5 pl-4 mono uppercase"
style={{ color: "var(--tt-ink-dim)", fontSize: 10, letterSpacing: "0.14em" }}
>
{payer}
</td>
<td className="py-2 pr-3">
<td className="py-2.5 pr-3">
<Sparkline breakdown={filled} />
</td>
</tr>
+121 -27
View File
@@ -1,9 +1,74 @@
import { useState } from "react";
import { InboxRow, type Accent } from "./InboxRow";
import type { InboxClaimRow, InboxCandidateRow } from "@/lib/inbox-api";
import { cn } from "@/lib/utils";
export type LaneRow = InboxClaimRow | InboxCandidateRow;
const ACCENT_COLOR: Record<Accent, string> = {
oxblood: "var(--tt-oxblood)",
amber: "var(--tt-amber)",
"ink-blue": "var(--tt-ink-blue)",
muted: "var(--tt-muted)",
};
/**
* Custom checkbox cell — a small square that picks up the lane's accent
* when selected. Built on a native `<input type="checkbox">` so the
* accessibility role and label-by-id wiring are free (the test harness
* looks the cell up with `screen.getByRole("checkbox")`).
*/
function RowCheckbox({
id,
checked,
onToggle,
}: {
id: string;
checked: boolean;
onToggle: () => void;
}) {
return (
<label
className="flex shrink-0 items-center justify-center cursor-pointer select-none"
style={{ width: 32 }}
>
<span className="relative inline-flex items-center justify-center">
<input
type="checkbox"
aria-label={`Select ${id}`}
checked={checked}
onChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="peer sr-only"
/>
<span
aria-hidden
className={cn(
"block h-3.5 w-3.5 rounded-[3px] border transition-all duration-100",
checked
? "bg-[color:var(--tt-amber)] border-[color:var(--tt-amber)] shadow-[0_0_8px_-1px_var(--tt-amber)]"
: "border-[color:var(--tt-muted)] bg-transparent peer-hover:border-[color:var(--tt-ink-dim)] peer-focus-visible:ring-2 peer-focus-visible:ring-[color:var(--tt-amber)] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[color:var(--tt-bg-elev)]"
)}
/>
{checked ? (
<svg
aria-hidden
viewBox="0 0 12 12"
className="pointer-events-none absolute h-2.5 w-2.5 text-[color:var(--tt-bg)]"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="2.5,6.5 5,9 9.5,3.5" />
</svg>
) : null}
</span>
</label>
);
}
export function Lane({
name,
accent,
@@ -31,29 +96,56 @@ export function Lane({
onSelectionChange?.(Array.from(next));
}
const accentColor = ACCENT_COLOR[accent];
return (
<section
className="flex-1 min-w-[280px] flex flex-col"
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
style={{
background: "var(--tt-bg-elev)",
border: "1px solid var(--tt-bg)",
borderRadius: 4,
border: "1px solid hsl(220 8% 18%)",
boxShadow:
"inset 0 1px 0 0 hsl(0 0% 100% / 0.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
}}
data-lane={name.toLowerCase()}
>
<header
className="px-3 py-2 flex items-baseline justify-between"
style={{ borderBottom: "1px solid var(--tt-bg)" }}
className="px-3 py-2.5 flex items-baseline justify-between gap-2"
style={{
borderBottom: "1px solid hsl(220 8% 16%)",
background:
"linear-gradient(180deg, hsl(220 16% 11%) 0%, hsl(220 18% 9%) 100%)",
}}
>
<div className="flex items-center gap-2">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full"
style={{
background: accentColor,
boxShadow: `0 0 8px ${accentColor}`,
}}
/>
<h2
className="font-mono uppercase tracking-widest"
style={{ color: "var(--tt-ink)", fontSize: 11, letterSpacing: "0.12em" }}
className="mono uppercase"
style={{
color: "var(--tt-ink)",
fontSize: 11,
letterSpacing: "0.18em",
fontWeight: 600,
}}
>
{name}
</h2>
</div>
<span
className="font-mono tabular-nums"
style={{ color: "var(--tt-amber)", fontSize: 18 }}
className="mono tabular-nums"
style={{
color: "var(--tt-amber)",
fontSize: 18,
fontWeight: 600,
lineHeight: 1,
}}
>
{rows.length}
</span>
@@ -61,34 +153,36 @@ export function Lane({
{rows.length === 0 ? (
<p
className="p-4 font-mono"
style={{ color: "var(--tt-muted)", fontSize: 11 }}
className="px-4 py-6 mono text-center"
style={{ color: "var(--tt-muted)", fontSize: 11, letterSpacing: "0.06em" }}
>
Nothing here.
</p>
) : (
<div className="w-full" style={{ fontSize: 12 }}>
{rows.map((row) => {
<div className="w-full">
{rows.map((row, idx) => {
const id = rowId(row);
const isSelected = selected.has(id);
return (
<div
key={id}
className="flex items-stretch"
style={{ borderBottom: "1px solid var(--tt-bg)" }}
className={cn(
"group flex items-stretch transition-colors",
isSelected
? "bg-[color:var(--tt-amber)]/8"
: idx % 2 === 0
? "bg-transparent"
: "bg-white/[0.015]",
"hover:bg-[color:var(--tt-amber)]/5"
)}
style={{ borderBottom: "1px solid hsl(220 8% 14%)" }}
>
<label
className="flex items-center justify-center"
style={{ width: 28, padding: "0 4px 0 8px" }}
>
<input
type="checkbox"
aria-label={`Select ${id}`}
checked={selected.has(id)}
onChange={() => toggle(id)}
onClick={(e) => e.stopPropagation()}
<RowCheckbox
id={id}
checked={isSelected}
onToggle={() => toggle(id)}
/>
</label>
<div style={{ width: "100%" }}>
<div className="flex-1 min-w-0">
<InboxRow row={row} accent={accent} onClick={() => onRowClick(row)} />
</div>
</div>
+8 -6
View File
@@ -3,20 +3,22 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10.5px] font-medium uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
secondary: "border-transparent bg-muted text-muted-foreground",
secondary: "border-transparent bg-muted/80 text-muted-foreground",
success:
"border-transparent bg-[hsl(var(--success)/0.15)] text-[hsl(var(--success))]",
"border-transparent bg-[hsl(var(--success)/0.14)] text-[hsl(var(--success))]",
warning:
"border-transparent bg-[hsl(var(--warning)/0.15)] text-[hsl(var(--warning))]",
"border-transparent bg-[hsl(var(--warning)/0.14)] text-[hsl(var(--warning))]",
destructive:
"border-transparent bg-destructive/15 text-destructive",
outline: "border-border text-foreground",
muted: "border-transparent bg-muted/60 text-muted-foreground",
outline: "border-border/80 text-foreground",
muted: "border-transparent bg-muted/40 text-muted-foreground",
signal:
"border-transparent bg-[hsl(var(--signal)/0.14)] text-[hsl(var(--signal))]",
},
},
defaultVariants: { variant: "default" },
+10 -10
View File
@@ -4,28 +4,28 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 active:translate-y-px",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-[0_1px_0_0_hsl(0_0%_100%/0.08)_inset,0_1px_2px_0_hsl(0_0%_0%/0.4)] hover:bg-primary/90",
accent:
"bg-accent text-accent-foreground shadow-sm hover:bg-accent/90",
"bg-accent text-accent-foreground shadow-[0_1px_0_0_hsl(0_0%_100%/0.1)_inset,0_1px_2px_0_hsl(0_0%_0%/0.4)] hover:bg-accent/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"bg-destructive text-destructive-foreground shadow-[0_1px_0_0_hsl(0_0%_100%/0.1)_inset,0_1px_2px_0_hsl(0_0%_0%/0.4)] hover:bg-destructive/90",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted",
"border border-border/80 bg-transparent text-foreground hover:bg-muted/60 hover:border-border",
secondary:
"bg-secondary text-secondary-foreground hover:bg-muted",
ghost: "bg-transparent text-foreground hover:bg-muted",
ghost: "bg-transparent text-foreground hover:bg-muted/60",
link: "bg-transparent text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-lg px-6",
icon: "h-9 w-9",
default: "h-9 px-4",
sm: "h-7 rounded px-2.5 text-xs",
lg: "h-10 px-5",
icon: "h-8 w-8",
},
},
defaultVariants: {
-25
View File
@@ -22,23 +22,6 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
);
CardHeader.displayName = "CardHeader";
// Polymorphic `as` prop so consumers can pick the correct heading level
// for semantic structure (e.g. `<CardTitle as="h2">` inside a Dashboard
// section). Defaults to `"div"` so all existing call-sites that omit
// `as` continue to render a <div> exactly as before — no regression.
//
// Implementation note: `React.forwardRef` infers its ref/props types
// from the inner render function, but TypeScript can't propagate a
// generic parameter through `forwardRef`'s own declaration. The
// well-known workaround (see Ben Ilegbodu's polymorphic-component
// write-up, mirrored by Mantine / tsteele.dev) is to give `forwardRef`
// concrete types up front and then cast the resulting component to a
// generic call signature so consumers get element-specific props
// (e.g. h2 attrs) and the right ref type based on `as`. The inner
// render uses `as` to swap the actual rendered element at runtime;
// the type assertion on `ref` is safe because React only cares about
// the `current` property at runtime regardless of the underlying
// DOM node type.
type CardTitleProps<E extends React.ElementType = "div"> = Omit<
React.ComponentPropsWithoutRef<E>,
"as"
@@ -46,12 +29,6 @@ type CardTitleProps<E extends React.ElementType = "div"> = Omit<
as?: E;
};
// The exported component is a polymorphic callable: a single call
// signature with the generic `E` parameter that consumers control
// via the `as` prop. We assign forwardRef's result through an
// `unknown` cast (necessary because the source type is
// `ForwardRefExoticComponent` while the target is a plain generic
// function).
type CardTitleComponent = <E extends React.ElementType = "div">(
props: CardTitleProps<E> & { ref?: React.ForwardedRef<React.ElementRef<E>> }
) => React.ReactElement | null;
@@ -71,8 +48,6 @@ const CardTitle = React.forwardRef<HTMLDivElement, CardTitleProps<"div">>(
);
}
) as unknown as CardTitleComponent;
// Displayname for React DevTools — assignable via cast since the
// polymorphic call signature doesn't expose it.
(CardTitle as unknown as { displayName: string }).displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+11 -3
View File
@@ -18,42 +18,50 @@ const STATE_CONFIG: Record<
label: string;
icon: LucideIcon;
className: string;
dot: string;
}
> = {
submitted: {
label: "Submitted",
icon: Send,
className: "bg-accent/15 text-accent border-accent/30",
dot: "bg-accent",
},
received: {
label: "Received",
icon: Inbox,
className: "bg-accent/10 text-accent/80 border-accent/20",
className: "bg-accent/10 text-accent/85 border-accent/20",
dot: "bg-accent/70",
},
paid: {
label: "Paid",
icon: CheckCircle2,
className: "bg-success/15 text-success border-success/30",
dot: "bg-success",
},
partial: {
label: "Partial",
icon: AlertTriangle,
className: "bg-warning/15 text-warning border-warning/30",
dot: "bg-warning",
},
denied: {
label: "Denied",
icon: Ban,
className: "bg-destructive/15 text-destructive border-destructive/30",
dot: "bg-destructive",
},
reconciled: {
label: "Reconciled",
icon: CheckCheck,
className: "bg-success/15 text-success border-success/30",
dot: "bg-success",
},
reversed: {
label: "Reversed",
icon: RotateCcw,
className: "bg-warning/15 text-warning border-warning/30",
dot: "bg-warning",
},
};
@@ -70,12 +78,12 @@ export function ClaimStateBadge({
<Badge
variant="outline"
className={cn(
"gap-1.5 font-mono uppercase tracking-wider text-[10px]",
"gap-1.5 mono uppercase tracking-[0.14em] text-[10.5px] font-medium py-0.5",
cfg.className,
className,
)}
>
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
<Icon className="h-3 w-3" strokeWidth={2} aria-hidden />
{cfg.label}
</Badge>
);
+4 -4
View File
@@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/65 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 surface rounded-2xl p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 surface rounded-xl p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
@@ -40,7 +40,7 @@ const DialogContent = React.forwardRef<
{children}
<DialogPrimitive.Close
aria-label="Close dialog"
className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">Close</span>
@@ -72,7 +72,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn("text-base font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
+4 -5
View File
@@ -30,20 +30,19 @@ export function EmptyState({
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-2.5 py-14 text-center",
"flex flex-col items-center justify-center gap-2.5 py-16 text-center",
className,
)}
>
<div
aria-hidden
className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground"
className="relative h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 bg-muted/30 flex items-center justify-center text-muted-foreground"
>
<div className="absolute inset-0 rounded-full ring-1 ring-inset ring-accent/10" />
{icon ?? <Inbox className="h-4 w-4" strokeWidth={1.5} />}
</div>
{eyebrow ? (
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
<div className="eyebrow">{eyebrow}</div>
) : null}
<div className="text-sm text-muted-foreground/80 max-w-sm">
{message}
+3 -5
View File
@@ -30,7 +30,7 @@ export function ErrorState({
return (
<div
className={cn(
"surface rounded-xl border border-destructive/30 p-4",
"surface rounded-lg border border-destructive/30 p-4",
"flex items-center gap-3",
className,
)}
@@ -42,12 +42,10 @@ export function ErrorState({
aria-hidden
/>
<div className="flex-1 min-w-0">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
{eyebrow}
</div>
<div className="eyebrow text-destructive/80">{eyebrow}</div>
<div className="text-sm text-foreground/90 truncate">{message}</div>
{detail ? (
<div className="text-xs text-muted-foreground truncate mt-0.5">
<div className="text-xs text-muted-foreground truncate mt-0.5 mono">
{detail}
</div>
) : null}
+3 -5
View File
@@ -38,9 +38,7 @@ export function FilterChips({
aria-label="Filter"
>
{eyebrow ? (
<span className="mr-1 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</span>
<span className="eyebrow mr-1">{eyebrow}</span>
) : null}
{options.map((opt) => {
const isActive = opt.value === value;
@@ -52,7 +50,7 @@ export function FilterChips({
aria-checked={isActive}
onClick={() => onChange(isActive ? null : opt.value)}
className={cn(
"h-7 rounded-full px-3 text-xs font-medium transition-colors",
"h-7 rounded-full px-3 text-[11px] font-medium uppercase tracking-[0.1em] transition-colors",
"ring-1 ring-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isActive
? "bg-accent/15 text-accent ring-accent/40 hover:bg-accent/20"
@@ -69,7 +67,7 @@ export function FilterChips({
onClick={() => onChange(null)}
aria-label="Clear filter"
className={cn(
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
"h-7 rounded-full px-2.5 text-[11px] font-medium uppercase tracking-[0.1em] transition-colors",
"inline-flex items-center gap-1",
"text-muted-foreground hover:text-foreground",
"ring-1 ring-inset ring-border/40 hover:ring-border/60",
+9 -2
View File
@@ -9,7 +9,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-background/40 px-3 py-1 text-sm transition-colors",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground/60",
"focus-visible:outline-none focus-visible:bg-background/80 focus-visible:border-foreground/20",
"disabled:cursor-not-allowed disabled:opacity-40",
className
)}
{...props}
@@ -25,7 +29,10 @@ const Textarea = React.forwardRef<
<textarea
ref={ref}
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-md border border-input bg-background/40 px-3 py-2 text-sm",
"placeholder:text-muted-foreground/60",
"focus-visible:outline-none focus-visible:bg-background/80 focus-visible:border-foreground/20",
"disabled:cursor-not-allowed disabled:opacity-40",
className
)}
{...props}
+11 -4
View File
@@ -14,7 +14,11 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/40 px-3 py-1 text-sm",
"placeholder:text-muted-foreground/60",
"focus-visible:outline-none focus-visible:bg-background/80 focus-visible:border-foreground/20",
"disabled:cursor-not-allowed disabled:opacity-40",
"[&>span]:line-clamp-1",
className
)}
{...props}
@@ -35,7 +39,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md surface text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md surface-2 text-popover-foreground shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className
@@ -64,14 +68,17 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[highlighted]:outline data-[highlighted]:outline-1 data-[highlighted]:outline-accent data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"focus:bg-muted focus:text-foreground",
"data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[highlighted]:outline data-[highlighted]:outline-1 data-[highlighted]:outline-accent",
"data-[state=checked]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-40",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<Check className="h-3.5 w-3.5 text-accent" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+4 -4
View File
@@ -37,8 +37,8 @@ export function Skeleton({
return (
<div
className={cn(
"relative overflow-hidden bg-muted/30",
"ring-1 ring-inset ring-border/40",
"relative overflow-hidden rounded-md bg-muted/20",
"ring-1 ring-inset ring-border/30",
variantClass[variant],
className,
)}
@@ -49,10 +49,10 @@ export function Skeleton({
>
<div
aria-hidden
className="absolute inset-y-0 left-0 w-1/2 animate-shimmer"
className="absolute inset-y-0 left-0 w-2/3 animate-shimmer"
style={{
background:
"linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
"linear-gradient(90deg, transparent 0%, hsl(220 14% 96% / 0.04) 50%, transparent 100%)",
}}
/>
</div>
+6 -3
View File
@@ -20,7 +20,10 @@ const TableHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("[&_tr]:border-b [&_tr]:border-border/60", className)}
className={cn(
"[&_tr]:border-b [&_tr]:border-border/60 [&_tr]:bg-muted/20",
className
)}
{...props}
/>
));
@@ -45,7 +48,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/30 data-[state=selected]:bg-muted",
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/40 data-[state=selected]:bg-muted/50",
className
)}
{...props}
@@ -61,7 +64,7 @@ const TableHead = React.forwardRef<
ref={ref}
scope={scope}
className={cn(
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-9 px-4 text-left align-middle text-[10.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80 [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
+177 -141
View File
@@ -1,7 +1,16 @@
/* Inter Tight (UI sans) + JetBrains Mono (data/mono) for the claim-drawer
Modern palette. Loaded via Google Fonts CDN. Project default face (Geist
Mono) is preserved by fallback chains in --m-font-mono. */
@import url("https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
/* =====================================================================
Cyclone — design system
---------------------------------------------------------------------
A precision instrument for one operator on one machine. The visual
language borrows from Bloomberg terminals and Linear-grade
engineering tools: dark, hairline-strict, mono-heavy, with one
editorial serif accent for the moments that deserve weight.
---------------------------------------------------------------------
Type stack:
- Geist Sans · UI, body, controls
- Geist Mono · every number, every id, every code-like datum
- Instrument Serif · editorial display (hero numbers, headlines)
===================================================================== */
@tailwind base;
@tailwind components;
@@ -9,96 +18,98 @@
@layer base {
:root {
/* Refined Apple-HIG dark — true black with a whisper of blue */
--background: 240 6% 4%; /* #0a0a0c */
--foreground: 240 6% 96%; /* #f5f5f7 */
/* ---- Core surfaces ----
A graphite canvas with a whisper of blue. Slightly warm at the
midtones (--card) to keep the chrome from feeling sterile. */
--background: 222 18% 5%; /* #0b0c10 — near-black, cool */
--foreground: 220 14% 96%; /* #f4f5f7 — paper white */
--card: 240 5% 8%; /* #131316 */
--card-foreground: 240 6% 96%;
--card: 222 16% 8%; /* #11131a */
--card-foreground: 220 14% 96%;
--popover: 240 5% 9%;
--popover-foreground: 240 6% 96%;
--popover: 222 18% 9%; /* #13151c */
--popover-foreground: 220 14% 96%;
--muted: 240 4% 12%; /* #1e1e21 */
--muted-foreground: 240 4% 61%; /* #9a9a9f */
--muted: 222 12% 13%; /* #1f2229 */
--muted-foreground: 220 8% 64%; /* #9b9ea6 */
--secondary: 240 4% 12%;
--secondary-foreground: 240 6% 96%;
--secondary: 222 12% 13%;
--secondary-foreground: 220 14% 96%;
--accent: 217 100% 56%; /* #1a73ff — electric, more committed than stock */
/* ---- Accents ----
Electric blue (primary) for actions and emphasis. Amber (signal)
is reserved for "needs attention" states and the Inbox terminal
accents — never decorative. */
--accent: 212 100% 60%; /* #2e8aff — electric, modern */
--accent-foreground: 0 0% 100%;
--signal: 36 92% 56%; /* #f3a531 — warm amber */
--primary: 217 100% 56%;
--primary: 212 100% 60%;
--primary-foreground: 0 0% 100%;
--destructive: 1 100% 60%; /* #ff453a */
--destructive: 358 82% 60%; /* #ec4458 */
--destructive-foreground: 0 0% 100%;
--success: 142 71% 50%; /* #30d158 */
--success-foreground: 144 70% 10%;
--success: 152 64% 46%; /* #2cbd76 */
--success-foreground: 152 70% 8%;
--warning: 38 100% 56%; /* #ff9f0a */
--warning-foreground: 26 80% 14%;
--warning: 36 92% 56%; /* #f3a531 */
--warning-foreground: 26 80% 12%;
--border: 240 4% 14%; /* #232327 */
--input: 240 4% 14%;
--ring: 217 100% 56%;
--border: 222 10% 16%; /* #262932 */
--input: 222 10% 16%;
--ring: 212 100% 60%;
--sidebar: 240 6% 5%;
--sidebar-foreground: 240 6% 96%;
--sidebar-accent: 217 100% 56%;
--sidebar-border: 240 4% 12%;
/* ---- Sidebar ---- */
--sidebar: 222 20% 4%; /* darker than the page */
--sidebar-foreground: 220 14% 96%;
--sidebar-accent: 212 100% 60%;
--sidebar-border: 222 12% 12%;
--radius: 0.75rem; /* 12px */
/* ---- Document surface (drawer detail panels) ----
A faint warm paper. Keeps detail views distinct from the chrome
without going full light. Pairs with editorial serif headlines. */
--surface: 36 18% 96%; /* #f6f3ec */
--surface-ink: 30 12% 9%; /* #181614 */
--surface-ink-2: 30 6% 32%; /* #4f4b46 */
--surface-ink-3: 30 4% 52%; /* #85807a */
--surface-line: 30 14% 14%; /* #24221f */
--surface-line-soft: 30 14% 14% / 0.12;
/* ------------------------------------------------------------------
Modern palette (SP4 claim-drawer)
------------------------------------------------------------------
Light, document-feeling surface tokens for the drawer. Prefixed
`--m-` to avoid colliding with the project's existing tokens
(e.g. the existing `--accent: 217 100% 56%` electric blue, used
via Tailwind utilities everywhere). Same prefix family as the
`--sidebar-*` tokens above. The drawer's component CSS will
reference these via `var(--m-surface)` etc.
Coexists with the dark app shell — the drawer is rendered on a
light surface regardless of global theme.
------------------------------------------------------------------ */
--m-surface: #FAFAF7;
--m-ink-primary: #0A0A0A;
--m-ink-secondary: #6B6B6B;
--m-ink-tertiary: #A0A0A0;
--m-border-heavy: #1A1A1A;
--m-accent: #1D4ED8;
--m-accent-hover: #1E40AF;
--m-error: #DC2626;
--m-error-bg: #FEF2F2;
--m-success: #15803D;
--m-success-bg: #F0FDF4;
--m-warning: #B45309;
--m-warning-bg: #FFFBEB;
/* ---- Ticker-tape terminal (Inbox) ----
Bloomberg-coded: three accent rails on near-black, with the
paper-tone ink for legibility. */
--tt-bg: 220 22% 5%; /* #0a0d12 */
--tt-bg-elev: 220 18% 9%; /* #13171f */
--tt-ink: 36 18% 88%; /* #e2ddd0 */
--tt-ink-dim: 220 6% 58%; /* #8e9098 */
--tt-amber: 36 88% 56%; /* #efa836 */
--tt-oxblood: 354 50% 40%; /* #8a2d35 */
--tt-ink-blue: 212 38% 36%; /* #2e4d77 */
--tt-muted: 220 4% 38%; /* #5b5d62 */
/* ------------------------------------------------------------------
SP6 Inbox — Ticker Tape palette
------------------------------------------------------------------
Bloomberg-terminal vibe. Three accent rails (oxblood / amber /
ink-blue) on near-black surfaces. Prefixed `--tt-` to coexist
with the dark app shell tokens. The Inbox page is full-bleed
dark and reads as a separate working surface from the rest of
the app.
------------------------------------------------------------------ */
--tt-bg: #0a0e14;
--tt-bg-elev: #11161f;
--tt-ink: #e6e1d3;
--tt-ink-dim: #8a8b8e;
--tt-amber: #f4b942;
--tt-oxblood: #8b2c2c;
--tt-ink-blue: #2c4a6e;
--tt-muted: #4a4d54;
/* ---- Legacy aliases (--m-*)
Kept as thin shims so any older component still using the
"warm paper" surface family resolves to the same hue family.
New components should prefer --surface-* / --destructive / etc. */
--m-surface: hsl(var(--surface));
--m-ink-primary: hsl(var(--surface-ink));
--m-ink-secondary: hsl(var(--surface-ink-2));
--m-ink-tertiary: hsl(var(--surface-ink-3));
--m-border-heavy: hsl(var(--surface-line));
--m-font-mono: var(--font-mono);
--m-error: hsl(var(--destructive));
--m-error-bg: hsl(var(--destructive) / 0.08);
--m-success: hsl(var(--success));
--m-warning: hsl(var(--warning));
--m-accent: hsl(var(--accent));
/* Modern type stack — UI sans + data mono. Fallbacks preserve the
existing Geist Mono face for any element that hasn't opted in. */
--m-font-sans: "Inter Tight", ui-sans-serif, system-ui, sans-serif;
--m-font-mono: "JetBrains Mono", ui-monospace, "Geist Mono", monospace;
--radius: 0.625rem; /* 10px — slightly tighter */
/* ---- Type stack ---- */
--font-sans: "Geist", ui-sans-serif, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
--font-display: "Instrument Serif", "Iowan Old Style", "Apple Garamond", "Baskerville", "Times New Roman", serif;
}
* {
@@ -111,6 +122,7 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-feature-settings: "ss01", "cv11", "calt";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -127,20 +139,27 @@
z-index: 0;
background: radial-gradient(
55rem 35rem at 100% -15%,
hsla(218, 100%, 58%, 0.085),
hsla(212, 100%, 60%, 0.07),
transparent 62%
);
}
/* Fine grain — the substrate of a precision instrument. */
/* Hairline grid — the substrate of a precision instrument.
Sits behind everything, gives the dark surfaces a sense of
scale and the work a sense of "field". */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.045;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
opacity: 0.5;
background-image:
linear-gradient(to right, hsl(222 10% 16% / 0.18) 1px, transparent 1px),
linear-gradient(to bottom, hsl(222 10% 16% / 0.18) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
}
/* Custom scrollbar — minimal, hairline, Apple-style */
@@ -164,20 +183,15 @@
/* Focus ring — accent, 2px, with offset.
Components override this with Tailwind focus-visible:ring-* utilities
for a ring+offset combo that matches the rest of the design system.
This base rule covers anything that doesn't opt in (links in
prose, native checkboxes, etc.). */
for a ring+offset combo that matches the rest of the design system. */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-radius: 6px;
}
/* Respect reduced-motion users globally. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
@@ -193,11 +207,42 @@
font-feature-settings: "tnum", "ss01";
}
/* Display face — used for KPI numbers and data emphasis */
/* Display face — Instrument Serif. Used for the moments that need
editorial weight: hero numbers, page titles in document surfaces,
and the brand mark. */
.display {
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-family: var(--font-display);
font-weight: 400;
letter-spacing: -0.015em;
font-feature-settings: "ss01";
}
/* Data mono — Geist Mono with tabular-nums. The voice of every
id, code, dollar amount, and timestamp in the app. */
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.04em;
letter-spacing: -0.01em;
}
/* Soft card surface — a 1px hairline + a 1px inner highlight to
give the surface a sense of depth without a heavy shadow. */
.surface {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
0 1px 2px 0 hsl(0 0% 0% / 0.4);
}
/* Subtle elevated surface — used for cards that should lift a
little more than the default. */
.surface-2 {
background: linear-gradient(180deg, hsl(222 16% 9%) 0%, hsl(222 16% 7.5%) 100%);
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.05),
0 1px 3px 0 hsl(0 0% 0% / 0.5);
}
/* Hairline — a 1px separator that reads as a divider, not a border */
@@ -205,16 +250,8 @@
border-color: hsl(var(--border) / 0.6);
}
/* Soft card surface with a 1px hairline + subtle inner highlight */
.surface {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.035),
0 1px 2px 0 hsl(0 0% 0% / 0.5);
}
/* Active nav indicator — a thin left-aligned accent line */
/* Active nav indicator — a thin left-aligned accent line with a
subtle glow that matches the instrument aesthetic. */
.nav-active {
background-color: hsl(var(--muted) / 0.6);
color: hsl(var(--foreground));
@@ -231,8 +268,7 @@
box-shadow: 0 0 12px hsl(var(--accent) / 0.5);
}
/* Skip link — invisible until focused. Sits above the scan element on
first paint so it is the first Tab stop in the app. */
/* Skip link — invisible until focused. */
.skip-link {
position: fixed;
top: 0.75rem;
@@ -264,7 +300,6 @@
0 0 0 4px hsl(var(--ring)),
0 8px 24px hsl(0 0% 0% / 0.45);
}
/* Reduced motion: skip the slide. */
@media (prefers-reduced-motion: reduce) {
.skip-link {
transition: none;
@@ -277,44 +312,66 @@
align-items: center;
gap: 0.15rem;
height: 1.375rem;
min-width: 1.375rem;
padding: 0 0.4rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
font-family: "Geist Mono", ui-monospace, monospace;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.04em;
line-height: 1;
}
/* Section label — the all-caps eyebrow used everywhere */
.eyebrow {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: hsl(var(--muted-foreground));
}
/* Editorial serif headline — for hero numbers and document
surfaces. A single line height that breathes. */
.editorial {
font-family: var(--font-display);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.05;
}
/* Row hover state — slightly more refined than the default
table hover. A subtle tint + a left accent strip. */
.row-hover {
transition: background-color 150ms ease;
}
.row-hover:hover {
background-color: hsl(var(--muted) / 0.4);
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.text-pretty {
text-wrap: pretty;
}
.ring-inset-hairline {
box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.6);
}
}
/* ----------------------------------------------------------------------
Print styles — a clean paper-record rendering of detail drawers.
When an operator hits Cmd-P / Ctrl-P with the ClaimDrawer (or the
future RemitDrawer) open, we strip the app chrome (sidebar, header,
close button, skeletons, animations, dark theme), collapse the
dialog to a static full-width block, and let its section headers +
tables flow onto the page without internal scrolling or backdrop
bleed. Result: a readable, ink-friendly paper trail of the claim.
---------------------------------------------------------------------- */
@media print {
/* Hide the app chrome — only the drawer content matters on paper */
aside, /* sidebar */
header, /* app header within Layout */
[data-print="hide"],
button[aria-label^="Close"] {
aside, header, [data-print="hide"], button[aria-label^="Close"] {
display: none !important;
}
/* Reset the dark theme for ink savings and readability */
:root {
--background: #ffffff;
--foreground: #000000;
@@ -325,14 +382,10 @@
--border: #cccccc;
--accent: #1a1a1a;
}
/* Reset the page surface */
body {
background: #ffffff !important;
color: #000000 !important;
}
/* Drawer becomes a static block at full width */
[role="dialog"] {
position: static !important;
inset: auto !important;
@@ -344,8 +397,6 @@
box-shadow: none !important;
border: none !important;
}
/* Strip backdrop + animations */
[data-state="open"][data-radix-dialog-overlay],
.animate-fade-in,
.animate-shimmer,
@@ -353,27 +404,12 @@
animation: none !important;
transition: none !important;
}
/* Show the panel contents without internal scrolling */
[role="dialog"] * {
overflow: visible !important;
}
/* Tables break gracefully */
table {
page-break-inside: avoid;
}
/* Avoid splitting a section header from its content */
section,
h1, h2, h3 {
page-break-after: avoid;
}
/* Honor reduced motion (also applies to print preview) */
*,
*::before,
*::after {
table { page-break-inside: avoid; }
section, h1, h2, h3 { page-break-after: avoid; }
*, *::before, *::after {
animation-duration: 0s !important;
}
}
+14 -37
View File
@@ -4,6 +4,7 @@ import { useActivity } from "@/hooks/useActivity";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
import { PageHeader } from "@/components/PageHeader";
import { ActivityFeed } from "@/components/ActivityFeed";
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
import { Skeleton } from "@/components/ui/skeleton";
@@ -22,12 +23,6 @@ const VALID_KINDS: readonly ActivityKind[] = [
const VALID_SINCE: readonly SinceValue[] = ["1h", "24h", "7d", "all"];
/**
* Convert a relative "since" window into an ISO timestamp string for the
* backend's lexicographic `timestamp >= since` comparison. Memoized on
* `since` so the queryKey stays stable across re-renders (otherwise the
* refetch would fire on every Date.now() tick).
*/
function useSinceIso(since: SinceValue): string | undefined {
return useMemo(() => {
const now = Date.now();
@@ -41,8 +36,6 @@ function useSinceIso(since: SinceValue): string | undefined {
export function ActivityLog() {
const [searchParams, setSearchParams] = useSearchParams();
// URL → filter state. `searchParams` is stable as long as the URL is,
// so these are computed cheaply with useMemo.
const selectedKinds = useMemo<ActivityKind[]>(
() =>
searchParams
@@ -58,10 +51,6 @@ export function ActivityLog() {
? (sinceRaw as SinceValue)
: "all";
// Backend's `/api/activity` only accepts a single `kind` query param, so
// for multi-select we fetch the unfiltered set and filter client-side.
// Single-kind selections pass through to the backend (cheaper + benefits
// from any future server-side filtering).
const apiKind = selectedKinds.length === 1 ? selectedKinds[0] : undefined;
const sinceIso = useSinceIso(since);
@@ -73,9 +62,6 @@ export function ActivityLog() {
const allItems = data?.items ?? [];
// SP5: live-tail wiring. `useMergedTail` appends tail-arriving events
// to the base query result, then we apply the same client-side
// multi-kind filter so live events respect the active filter set.
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
useTailStream("activity");
const merged = useMergedTail("activity", allItems);
@@ -128,18 +114,19 @@ export function ActivityLog() {
const hasFilters = selectedKinds.length > 0 || since !== "all";
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>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Activity"
title={<>Activity <span className="display italic text-muted-foreground">log</span></>}
subtitle="Every claim submission, denial, payment, and provider event, in reverse chronological order. Auto-refreshes every 30s."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
}
/>
<div className="surface rounded-xl p-4">
<ActivityFilters
@@ -160,16 +147,6 @@ export function ActivityLog() {
) : null}
<div className="surface rounded-xl p-6">
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
+7 -53
View File
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/PageHeader";
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
import {
BatchDetailContent,
@@ -16,17 +17,6 @@ import { api, ApiError, type BatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { ParseResult837, ParseResult835 } from "@/types";
// ---------------------------------------------------------------------------
// Inline: batch drawer URL state.
//
// `useDrawerUrlState` (in src/hooks/) is hard-coded to `?claim=` — it
// exists to drive the claim detail drawer. Mirroring that shape for
// `?batch=` keeps the two drawers decoupled: opening a claim from
// elsewhere doesn't disturb the batch URL, and vice versa. Inlining
// (rather than creating a parallel hook file) keeps the change inside
// this workstream's scope.
// ---------------------------------------------------------------------------
function readBatchId(): string | null {
const params = new URLSearchParams(window.location.search);
const value = params.get("batch");
@@ -75,20 +65,6 @@ function useBatchDrawerUrlState(): {
return { batchId, open, close, setBatchId };
}
// ---------------------------------------------------------------------------
// Inline: per-batch detail query.
//
// `api.getBatch` throws plain `Error` with the HTTP status code at the
// start of the message (e.g. `"404 Not Found — batch not found"`),
// not `ApiError`. To drive the drawer's not-found branch via the same
// `ApiError.status === 404` check used everywhere else, this hook
// detects the `"404"` prefix in the message and rethrows as
// `ApiError(404, …)`. Other errors pass through unchanged.
//
// 30-second staleTime mirrors `useClaimDetail` so j/k navigation
// reuses prior fetches within the drawer session.
// ---------------------------------------------------------------------------
function useBatchDetail(id: string | null): {
data: (ParseResult837 | ParseResult835) | null;
isLoading: boolean;
@@ -140,10 +116,6 @@ function useBatchDetail(id: string | null): {
};
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
const HELP_NOOP = () => {};
export function Batches() {
@@ -155,10 +127,6 @@ export function Batches() {
const { data: detail, isLoading: detailLoading, error: detailError, refetch: refetchDetail } =
useBatchDetail(batchId);
// j/k navigation: derive next/prev batch IDs based on the current
// batch's index in the loaded list. Wrap around at both ends. If
// the current batch isn't in the list, the callbacks are no-ops —
// defensive against bad input.
const { onNext, onPrev } = useMemo(() => {
if (batchId === null || items.length === 0) {
return { onNext: () => {}, onPrev: () => {} };
@@ -182,9 +150,6 @@ export function Batches() {
onNext,
onPrev,
onClose: close,
// The Batches page doesn't ship a keyboard cheatsheet overlay —
// ? is silently swallowed here. Wiring it later is a no-op
// because useDrawerKeyboard only fires the callback.
onToggleHelp: HELP_NOOP,
});
@@ -200,9 +165,6 @@ export function Batches() {
}}
>
<DialogContent
// Right-anchored side panel, matching the claim-drawer's
// visual identity. We override the Dialog primitive's
// centered defaults so it behaves as a drawer (not a modal).
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
data-testid="batch-detail-drawer"
aria-describedby={undefined}
@@ -226,23 +188,15 @@ export function Batches() {
<div
data-testid="batches-page-body"
className={cn(
"space-y-8 animate-fade-in transition-opacity",
"space-y-6 lg:space-y-8 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60",
)}
>
<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" />
Batches
</div>
<h1 className="text-[22px] font-semibold tracking-tight">
Parsed batches
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every 837P and 835 file the backend has ingested. Click a row
for envelope, summary, and a peek at the contained claims.
</p>
</header>
<PageHeader
eyebrow="Batches"
title={<>Parsed <span className="display italic text-muted-foreground">batches</span></>}
subtitle="Every 837P and 835 file the backend has ingested. Click a row for envelope, summary, and a peek at the contained claims."
/>
{isError ? (
<ErrorState
+36 -69
View File
@@ -25,6 +25,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
import { Pagination } from "@/components/ui/pagination";
import { PageHeader } from "@/components/PageHeader";
import { useClaims } from "@/hooks/useClaims";
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
import { useTailStream } from "@/hooks/useTailStream";
@@ -55,10 +56,6 @@ export function Claims() {
const [helpOpen, setHelpOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
// SP4 drawer wiring: the URL is the source of truth for "which claim is
// open". `open(id)` pushState's a new history entry (Back returns to the
// list); `close()` pushState's the bare URL; `setClaimId(id)` replaceState's
// so j/k navigation doesn't pollute history with one entry per keystroke.
const { claimId, open, close, setClaimId } = useDrawerUrlState();
const providers = useAppStore((s) => s.providers);
@@ -78,15 +75,6 @@ export function Claims() {
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useClaims(params);
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 22).
//
// The tail stream emits every claim that lands in the DB — including
// ones that don't match the user's current `status` / `provider_npi`
// filter. Mirror the server-side filter the page already applies via
// `useClaims` so a newly-arrived item that wouldn't pass the same
// predicate is dropped before it can show up in the table. `useMergedTail`
// applies this to the tail slice only; base items are already filtered
// server-side.
const tailFilterFn = useMemo(
() => (c: Claim) => {
if (status !== ALL && c.status !== status) return false;
@@ -108,7 +96,6 @@ export function Claims() {
[items]
);
// Local search filter on the current page (cheap; no extra fetch).
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
@@ -122,21 +109,12 @@ export function Claims() {
}, [items, query]);
const filtered = status !== ALL || npi !== ALL;
// Dim the background list while the drawer is open so the user's eye
// lands on the drawer. `pointer-events-none` prevents accidental clicks
// on the dimmed list (e.g., clicking a row would open yet another
// drawer on top).
const dimBackground = claimId !== null;
return (
<>
<ClaimDrawer
claimId={claimId}
// Pass the full ordered list of ids from the current page so j/k
// can wrap around without an extra round-trip. `items` is the API
// response (post-pagination, post-search); that's the same set the
// user is looking at, so wrap-around feels right.
claims={items.map((c) => ({ id: c.id }))}
onClose={close}
onNavigate={setClaimId}
@@ -149,20 +127,25 @@ export function Claims() {
<div
data-testid="claims-page-body"
className={cn(
"space-y-8 animate-fade-in transition-opacity",
"space-y-6 lg:space-y-8 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60"
)}
>
<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>
<PageHeader
eyebrow="Claims"
title={<>All <span className="display italic text-muted-foreground">claims</span></>}
subtitle="Every claim submitted, accepted, denied, or paid — filtered, sorted, and live."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
}
status={
<NewClaimDialog />
</header>
}
/>
{isError ? (
<ErrorState
@@ -181,13 +164,13 @@ export function Claims() {
placeholder="Search by claim ID, patient, payer, CPT…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 pr-16"
className="pl-9 pr-10"
/>
{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"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md transition-colors hover:bg-muted/60"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
@@ -214,19 +197,9 @@ export function Claims() {
))}
</SelectContent>
</Select>
{/* SP5: live-tail status pill. `ml-auto` pins it to the right
edge of the toolbar so it doesn't crowd the search input. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
<div className="mt-3">
<div className="mt-3.5">
<FilterChips
options={STATUS_OPTIONS}
value={status === ALL ? null : status}
@@ -238,25 +211,28 @@ export function Claims() {
/>
</div>
<div className="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 mt-4 pt-4 border-t border-border/30 mono text-[11px] text-muted-foreground">
<span>
<span className="num text-foreground font-medium">
<span className="text-foreground font-medium">
{fmt.num(data?.total ?? 0)}
</span>{" "}
claims
</span>
<span>
Billed{" "}
<span className="num text-foreground font-medium">
<span className="text-foreground font-medium">
{fmt.usd(totals.billed)}
</span>
</span>
<span>
Received{" "}
<span className="num text-foreground font-medium">
<span className="text-foreground font-medium">
{fmt.usd(totals.received)}
</span>
</span>
<span className="ml-auto text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/70">
Press <span className="kbd mx-1">?</span> for shortcuts
</span>
</div>
</div>
@@ -297,44 +273,35 @@ export function Claims() {
return (
<TableRow
key={`${c.id}-${dataUpdatedAt}`}
// Click anywhere on the row to open the drawer for
// this claim. `cursor-pointer` makes the affordance
// obvious; the existing `hover:bg-muted/30` from the
// TableRow primitive stays in place.
onClick={() => open(c.id)}
className={cn(
"animate-row-flash cursor-pointer",
// Re-keying on dataUpdatedAt re-mounts the row on
// every refetch (initial load, filter change, parse
// invalidation) so the row-flash keyframe replays.
)}
className={cn("animate-row-flash cursor-pointer")}
>
<TableCell>
<div className="display num text-[13px]">{c.id}</div>
<div className="text-[11px] text-muted-foreground num">
<div className="display mono text-[12.5px] text-foreground">{c.id}</div>
<div className="mono text-[10.5px] text-muted-foreground">
CPT {c.cptCode}
</div>
</TableCell>
<TableCell className="font-medium">{c.patientName}</TableCell>
<TableCell className="font-medium text-[13px]">{c.patientName}</TableCell>
<TableCell>
<div className="text-sm">{provider?.name ?? "Unknown"}</div>
<div className="text-[11px] text-muted-foreground num">
<div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
<div className="mono text-[10.5px] text-muted-foreground">
{c.providerNpi}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
<TableCell className="text-muted-foreground text-[13px]">
{c.payerName}
</TableCell>
<TableCell className="text-right display num">
<TableCell className="text-right display mono">
{fmt.usd(c.billedAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
<TableCell className="text-right display mono text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground text-[13px] num">
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(c.submissionDate)}
</TableCell>
</TableRow>
+54 -47
View File
@@ -9,6 +9,7 @@ import {
TrendingDown,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/PageHeader";
import { KpiCard } from "@/components/KpiCard";
import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
@@ -49,7 +50,6 @@ function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"])
months[i]!.received += c.receivedAmount;
if (c.status === "denied") months[i]!.denied += 1;
}
// Running outstanding AR (cumulative billed cumulative received)
let running = 0;
const ar: number[] = [];
for (const m of months) {
@@ -92,7 +92,7 @@ export function Dashboard() {
const monthly = useMemo(() => buildMonthly(claims), [claims]);
const topProviders = useMemo(
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 3),
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 4),
[providers]
);
@@ -101,28 +101,30 @@ export function Dashboard() {
[claims]
);
// Stagger choreography
// Stagger choreography — the hero lands first, then the KPIs in
// a left-to-right wave, then the supporting cards. Total
// choreography fits under 700ms.
const heroDelay = 0;
const kpiBase = 120;
const kpiStep = 70;
const sectionBase = kpiBase + kpiStep * 5 + 60; // after the last KPI
const kpiStep = 60;
const sectionBase = kpiBase + kpiStep * 5 + 80;
return (
<div className="space-y-6 md:space-y-10">
{/* Hero */}
<header
<div className="space-y-6 lg:space-y-10">
{/* Hero — the dashboard's only moment of editorial display. The
ghost total sits behind the greeting at single-digit opacity
so it reads as a watermark, not a competing headline. */}
<section
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
<div className="relative">
{/* The signature element: a ghosted total behind the greeting. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute left-0 top-1/2 -translate-y-1/2 whitespace-nowrap display font-medium text-foreground"
className="pointer-events-none select-none absolute -left-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{
fontSize: "clamp(80px, 14vw, 168px)",
letterSpacing: "-0.05em",
opacity: 0.055,
letterSpacing: "-0.04em",
opacity: 0.04,
lineHeight: 1,
}}
>
@@ -131,19 +133,19 @@ export function Dashboard() {
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<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" />
Today · {fmt.date(new Date().toISOString())}
<PageHeader
eyebrow={`Today · ${fmt.date(new Date().toISOString())}`}
title={<>Good morning, Jordan.</>}
subtitle={
<>
<span className="display text-foreground">Three NPIs</span> are
in flight. {kpis.pending} claims pending, clearinghouse cycle
is normal.
</>
}
/>
</div>
<h1 className="text-[22px] sm:text-[28px] leading-[1.15] font-semibold tracking-tight">
Good morning, Jordan.
</h1>
<p className="text-muted-foreground mt-1.5 max-w-xl text-[14px] leading-relaxed">
Three NPIs are in flight. {kpis.pending} claims pending,
clearinghouse cycle is normal.
</p>
</div>
<div className="flex items-center gap-2.5 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur">
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] text-muted-foreground backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
@@ -151,19 +153,20 @@ export function Dashboard() {
Clearinghouse live
</div>
</div>
</div>
</header>
</section>
{/* KPIs — every card carries a trend. */}
{/* KPIs — five tiles, each carrying a sparkline. Staggered
fade-in on first paint for a "the instrument powers up"
feel. */}
<section
aria-label="Key performance indicators"
className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
className="grid gap-3.5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
>
<KpiCard
label="Claims"
icon={Receipt}
sparkline={monthly.count}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
value={
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
@@ -173,8 +176,9 @@ export function Dashboard() {
<KpiCard
label="Billed"
icon={CircleDollarSign}
accent="accent"
sparkline={monthly.billed}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
delta={{ value: "+12.4%", direction: "up", positive: true }}
@@ -182,8 +186,9 @@ export function Dashboard() {
<KpiCard
label="Received"
icon={Banknote}
accent="success"
sparkline={monthly.received}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
delta={{ value: "+8.1%", direction: "up", positive: true }}
@@ -191,8 +196,9 @@ export function Dashboard() {
<KpiCard
label="Pending AR"
icon={Clock}
accent="warning"
sparkline={monthly.ar}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
value={
<AnimatedNumber
@@ -205,8 +211,9 @@ export function Dashboard() {
<KpiCard
label="Denial rate"
icon={TrendingDown}
accent="destructive"
sparkline={monthly.denialRate}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
value={
<AnimatedNumber
@@ -220,7 +227,7 @@ export function Dashboard() {
{/* Activity + Top providers */}
<section
className="grid gap-4 lg:grid-cols-3 animate-fade-in"
className="grid gap-4 lg:grid-cols-3 animate-fade-in-up"
style={{ animationDelay: `${sectionBase}ms` }}
>
<Card className="lg:col-span-2">
@@ -234,7 +241,7 @@ export function Dashboard() {
Newest submissions, denials, and remittances across all providers.
</p>
</div>
<span className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground num">
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
{activity.length} events
</span>
</CardHeader>
@@ -254,18 +261,18 @@ export function Dashboard() {
<ul className="space-y-4">
{topProviders.map((p, i) => (
<li key={p.npi} className="flex items-center gap-3">
<div className="h-7 w-7 rounded-md bg-muted/60 flex items-center justify-center text-[11px] font-medium num text-muted-foreground">
<div className="h-7 w-7 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center mono text-[10.5px] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{p.name}</div>
<div className="text-[11px] text-muted-foreground num">
<div className="text-[13px] font-medium truncate">{p.name}</div>
<div className="mono text-[10.5px] text-muted-foreground">
NPI {p.npi}
</div>
</div>
<div className="text-right">
<div className="display text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="text-[11px] text-muted-foreground num">
<div className="display mono text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="mono text-[10.5px] text-muted-foreground">
{fmt.usd(p.outstandingAr)} AR
</div>
</div>
@@ -278,7 +285,7 @@ export function Dashboard() {
{topDenials.length > 0 ? (
<section
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${sectionBase + 90}ms` }}
>
<Card>
@@ -301,20 +308,20 @@ export function Dashboard() {
key={c.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="display text-[13px] num text-muted-foreground pt-0.5 w-24 shrink-0">
<div className="display mono text-[12.5px] text-muted-foreground pt-0.5 w-24 shrink-0">
{c.id}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm">{c.patientName}</div>
<div className="text-[12px] text-muted-foreground">
<div className="text-[13px]">{c.patientName}</div>
<div className="text-[11.5px] text-muted-foreground">
{c.denialReason ?? "—"}
</div>
</div>
<div className="text-right shrink-0">
<div className="display text-[14px] num">
<div className="display mono text-[13.5px]">
{fmt.usd(c.billedAmount)}
</div>
<div className="text-[11px] text-muted-foreground">
<div className="text-[10.5px] text-muted-foreground">
{c.payerName}
</div>
</div>
+65 -20
View File
@@ -135,10 +135,15 @@ export default function Inbox() {
if (loading) {
return (
<div
className="min-h-screen p-8 font-mono"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)" }}
className="min-h-screen p-8 mono flex items-center gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)", fontSize: 12 }}
>
loading
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber)" }}
/>
loading inbox
</div>
);
}
@@ -146,10 +151,13 @@ export default function Inbox() {
if (error) {
return (
<div
className="min-h-screen p-8 font-mono"
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)" }}
className="min-h-screen p-8 mono flex flex-col gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)", fontSize: 12 }}
>
error: {error.message}
<span className="uppercase tracking-[0.18em]" style={{ fontSize: 10, fontWeight: 700 }}>
Connection error
</span>
<span>{error.message}</span>
</div>
);
}
@@ -163,7 +171,7 @@ export default function Inbox() {
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
<main className="p-4 flex gap-4 items-start flex-wrap">
<main className="px-6 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
<Lane
name="REJECTED"
accent="oxblood"
@@ -229,42 +237,78 @@ export default function Inbox() {
regenerated 837 files. Single-claim resubmit skips this. */}
{bundleModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "rgba(0,0,0,0.55)" }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(4px)" }}
data-testid="resubmit-bundle-modal"
role="dialog"
aria-label="Resubmit and download bundle"
>
<div
className="w-[min(32rem,90vw)] rounded-md border p-6 font-mono"
className="w-[min(34rem,90vw)] rounded-md border p-6 mono"
style={{
background: "var(--tt-bg)",
borderColor: "var(--tt-amber)",
color: "var(--tt-ink)",
boxShadow:
"0 0 0 1px hsl(36 88% 56% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.7), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
}}
>
<h2
className="mb-3 text-sm uppercase tracking-[0.18em]"
<div className="flex items-center gap-2 mb-3">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full animate-pulse-dot"
style={{
background: "var(--tt-amber)",
boxShadow: "0 0 8px var(--tt-amber)",
}}
/>
<span
className="eyebrow"
style={{ color: "var(--tt-amber)" }}
>
Resubmit batch
</span>
</div>
<h2
className="display text-2xl mb-3"
style={{ color: "var(--tt-ink)", letterSpacing: "-0.02em" }}
>
Resubmit {selected.rejected.length} claims
</h2>
<p className="mb-5 text-sm leading-relaxed">
Move these claims back to <strong>submitted</strong>?
You can also download a bundle of regenerated 837 files
<p
className="mb-6 text-sm leading-relaxed"
style={{ color: "var(--tt-ink-dim)" }}
>
Move these claims back to{" "}
<span
className="px-1.5 py-0.5 rounded-sm"
style={{
background: "var(--tt-amber)",
color: "var(--tt-bg)",
fontWeight: 700,
}}
>
submitted
</span>
? Optionally download a bundle of regenerated 837 files
(one <code>.x12</code> per claim) useful if you're
about to ship them to the clearinghouse.
</p>
<div className="flex justify-end gap-3">
<div className="flex justify-end gap-2.5">
<button
type="button"
onClick={onResubmitOnly}
disabled={bundleSubmitting}
data-testid="resubmit-modal-only"
className="border px-4 py-2 text-xs uppercase tracking-wider disabled:opacity-50"
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
style={{
borderColor: "var(--tt-amber)",
border: "1px solid var(--tt-amber)",
color: "var(--tt-amber)",
background: "transparent",
fontWeight: 600,
}}
>
Resubmit only
@@ -274,10 +318,11 @@ export default function Inbox() {
onClick={onResubmitAndDownload}
disabled={bundleSubmitting}
data-testid="resubmit-modal-download"
className="px-4 py-2 text-xs uppercase tracking-wider disabled:opacity-50"
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:brightness-110 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--tt-amber)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--tt-bg)]"
style={{
background: "var(--tt-amber)",
color: "var(--tt-bg)",
fontWeight: 700,
}}
>
Resubmit + Download
@@ -294,7 +339,7 @@ export default function Inbox() {
{bundleSubmitting && (
<div
className="fixed inset-0 z-40 cursor-wait"
style={{ background: "rgba(0,0,0,0.15)" }}
style={{ background: "rgba(0,0,0,0.15)", backdropFilter: "blur(1px)" }}
data-testid="resubmit-progress-overlay"
aria-hidden="true"
/>
+22 -31
View File
@@ -2,6 +2,7 @@ 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 { PageHeader } from "@/components/PageHeader";
import { useProviders } from "@/hooks/useProviders";
import { fmt } from "@/lib/format";
@@ -10,18 +11,12 @@ export function Providers() {
const items = data?.items ?? [];
return (
<div className="space-y-6 md: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] sm:text-[26px] 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>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Providers"
title={<>Provider <span className="display italic text-muted-foreground">directory</span></>}
subtitle="NPIs registered with the clearinghouse for 837P submission and 835 remittance retrieval."
/>
{isError ? (
<ErrorState
@@ -45,53 +40,49 @@ export function Providers() {
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3.5 md:grid-cols-2 xl:grid-cols-3">
{items.map((p) => (
<article
key={p.npi}
className="surface rounded-xl p-4 sm:p-6 flex flex-col gap-4 hover:bg-muted/20 transition-colors"
className="group surface-2 rounded-xl p-5 flex flex-col gap-4 transition-colors hover:bg-muted/20 cursor-default"
>
<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 className="h-10 w-10 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center text-foreground">
<Building2 className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-semibold tracking-tight">
<h3 className="display text-[15px] text-foreground tracking-tight">
{p.name}
</h3>
<p className="text-[11px] text-muted-foreground num mt-0.5">
<p className="mono text-[10.5px] text-muted-foreground 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} />
<div className="space-y-1.5 text-[12.5px] text-muted-foreground">
<div className="flex items-start gap-2">
<MapPin className="h-3.5 w-3.5 mt-0.5 shrink-0" 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>
<span className="mono">{p.phone}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/30">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Claims
</div>
<div className="display text-[20px] leading-none mt-1.5">
<div className="eyebrow">Claims</div>
<div className="display mono text-[20px] leading-none mt-2">
{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">
<div className="eyebrow">Outstanding AR</div>
<div className="display mono text-[20px] leading-none mt-2">
{fmt.usd(p.outstandingAr)}
</div>
</div>
+42 -67
View File
@@ -6,6 +6,7 @@ import { ApiError } from "@/lib/api";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,10 +16,6 @@ import { cn } from "@/lib/utils";
* selected" to POST /api/reconciliation/match. The hook's invalidation
* cascade refreshes the unmatched bucket + claims + remits + activity feeds
* on success.
*
* Loading / error / empty states each early-return so the two-column
* selection JSX stays readable and doesn't have to defensively check
* `unmatched.data` everywhere.
*/
export function ReconciliationPage() {
const { unmatched, match } = useReconciliation();
@@ -27,16 +24,11 @@ export function ReconciliationPage() {
if (unmatched.isLoading) {
return (
<div className="space-y-6 md: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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Skeleton variant="default" height={320} />
<Skeleton variant="default" height={320} />
@@ -51,16 +43,11 @@ export function ReconciliationPage() {
? unmatched.error.message
: String(unmatched.error);
return (
<div className="space-y-6 md: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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
/>
<ErrorState
message="Couldn't load unmatched claims and remittances."
detail={detail}
@@ -78,16 +65,11 @@ export function ReconciliationPage() {
if (empty) {
return (
<div className="space-y-6 md: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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
/>
<div className="surface rounded-xl">
<EmptyState
icon={<CircleDashed className="h-4 w-4" strokeWidth={1.5} />}
@@ -107,8 +89,6 @@ export function ReconciliationPage() {
setSelectedClaim(null);
setSelectedRemit(null);
} catch (e) {
// ApiError carries the HTTP status; 409 is the backend's "already
// matched" signal, so surface a friendlier message than the raw body.
const msg =
e instanceof ApiError && e.status === 409
? "Already matched — view the existing match."
@@ -125,25 +105,19 @@ export function ReconciliationPage() {
};
return (
<div className="space-y-6 md: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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Pick one claim and one remittance, then match them.
</p>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
subtitle="Pick one claim and one remittance, then match them."
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Claims column */}
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Unmatched claims ({claims.length})
<div className="surface-2 rounded-xl p-4 space-y-2">
<h2 className="eyebrow flex items-center justify-between">
Unmatched claims
<span className="mono text-[10.5px] text-foreground/80">{claims.length}</span>
</h2>
<div className="space-y-2">
{claims.map((c) => {
@@ -155,17 +129,17 @@ export function ReconciliationPage() {
onClick={() => setSelectedClaim(c.id)}
aria-pressed={active}
className={cn(
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors",
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors text-left",
active
? "border-accent bg-accent/5"
: "border-border/60 hover:border-border"
? "border-accent bg-accent/10 ring-1 ring-inset ring-accent/30"
: "border-border/60 hover:border-border hover:bg-muted/30"
)}
>
<div className="display num text-sm">{c.id}</div>
<div className="font-mono text-xs text-muted-foreground">
<div className="display mono text-[13px]">{c.id}</div>
<div className="text-[12px] text-muted-foreground">
{c.patientName}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
<div className="mono text-[11px] text-muted-foreground mt-0.5">
{c.serviceDate ?? "—"} · ${c.billedAmount.toFixed(2)} · NPI{" "}
{c.providerNpi ?? "—"}
</div>
@@ -176,9 +150,10 @@ export function ReconciliationPage() {
</div>
{/* Remits column */}
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Unmatched remits ({remittances.length})
<div className="surface-2 rounded-xl p-4 space-y-2">
<h2 className="eyebrow flex items-center justify-between">
Unmatched remits
<span className="mono text-[10.5px] text-foreground/80">{remittances.length}</span>
</h2>
<div className="space-y-2">
{remittances.map((r) => {
@@ -190,13 +165,13 @@ export function ReconciliationPage() {
onClick={() => setSelectedRemit(r.id)}
aria-pressed={active}
className={cn(
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors",
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors text-left",
active
? "border-accent bg-accent/5"
: "border-border/60 hover:border-border"
? "border-accent bg-accent/10 ring-1 ring-inset ring-accent/30"
: "border-border/60 hover:border-border hover:bg-muted/30"
)}
>
<div className="font-mono text-sm flex items-center gap-2">
<div className="mono text-[13px] flex items-center gap-2">
{r.payerClaimControlNumber}
{r.isReversal ? (
<span className="text-[10px] text-warning uppercase tracking-wider">
@@ -204,7 +179,7 @@ export function ReconciliationPage() {
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
<div className="mono text-[11px] text-muted-foreground mt-0.5">
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
{r.adjustmentAmount.toFixed(2)} adj
</div>
@@ -234,7 +209,7 @@ export function ReconciliationPage() {
Clear selection
</Button>
{selectedClaim || selectedRemit ? (
<span className="text-xs text-muted-foreground sm:ml-2 basis-full sm:basis-auto">
<span className="text-[11.5px] text-muted-foreground sm:ml-2 basis-full sm:basis-auto mono">
{selectedClaim ? `Claim ${selectedClaim}` : "No claim"}
{" · "}
{selectedRemit ? `Remit ${selectedRemit}` : "No remit"}
+40 -68
View File
@@ -15,11 +15,12 @@ import { ErrorState } from "@/components/ui/error-state";
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
import { Pagination } from "@/components/ui/pagination";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { PageHeader } from "@/components/PageHeader";
import { TailStatusPill } from "@/components/TailStatusPill";
import { useRemittances } from "@/hooks/useRemittances";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
@@ -39,19 +40,19 @@ const STATUS_OPTIONS: FilterChipOption[] = [
*/
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
return (
<div className="flex items-start justify-between gap-4 py-1.5 border-b border-border/40 last:border-0">
<div className="flex items-start justify-between gap-4 py-2 border-b border-border/30 last:border-0">
<div className="min-w-0 flex-1">
<div className="font-mono text-[11px] text-muted-foreground">
<div className="mono text-[10.5px] text-muted-foreground">
{adj.group}-{adj.reason}
{adj.quantity !== null ? (
<span className="ml-2 text-muted-foreground/70">
<span className="ml-2 text-muted-foreground/60">
qty {adj.quantity}
</span>
) : null}
</div>
<div className="text-[13px] text-foreground/90 truncate">{adj.label}</div>
<div className="text-[12.5px] text-foreground/90 truncate">{adj.label}</div>
</div>
<div className="display num text-[13px] tabular-nums whitespace-nowrap text-muted-foreground">
<div className="display mono text-[12.5px] tabular-nums whitespace-nowrap text-muted-foreground">
{fmt.usdPrecise(adj.amount)}
</div>
</div>
@@ -62,9 +63,6 @@ export function Remittances() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState<RemittanceStatus | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
// Row navigation (j/k) lives in parallel with `expanded`; selection
// is purely a focus-state indicator and does NOT auto-expand the
// row (so j/k doesn't fight the existing click-to-expand gesture).
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
@@ -75,13 +73,6 @@ export function Remittances() {
offset: (page - 1) * PAGE_SIZE,
});
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 23). The tail
// stream emits every remittance that lands — including ones that
// don't match the user's current `status` filter chip. Drop those
// tail items so a newly-arrived remit that wouldn't match the page's
// intent doesn't flash into the table. Base items are NOT filtered
// here (they reflect whatever the list query returned, matching the
// existing page behavior).
const tailFilterFn = useMemo(
() => (r: Remittance) => {
if (status && r.status !== status) return false;
@@ -123,15 +114,10 @@ export function Remittances() {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return items.length - 1;
// Add items.length before the modulo so the negative index
// (first row → wrap to last) wraps correctly.
return (i - 1 + items.length) % items.length;
});
}, [items.length]);
// `enabled: !helpOpen` so j/k yields to the cheatsheet while it's
// open (the cheatsheet's own dismiss listener still closes it on
// any non-`?` keypress). See Acks.tsx for the full rationale.
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
onNext: moveNext,
@@ -146,17 +132,19 @@ export function Remittances() {
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<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>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Remittances"
title={<>835 <span className="display italic text-muted-foreground">remittances</span></>}
subtitle="Electronic remittance advice from payers, matched to submitted claims."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
}
/>
{isError ? (
<ErrorState
@@ -166,29 +154,28 @@ export function Remittances() {
/>
) : 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 className="grid gap-3.5 grid-cols-2 lg:grid-cols-3">
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow">Remits</div>
<div className="display mono text-[28px] leading-none mt-3 text-foreground">
{fmt.num(data?.total ?? 0)}
</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 className="surface-2 rounded-xl p-5">
<div className="eyebrow">Total paid</div>
<div className="display mono text-[28px] leading-none mt-3 text-[hsl(var(--success))]">
{fmt.usd(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 className="surface-2 rounded-xl p-5">
<div className="eyebrow">Adjustments</div>
<div className="display mono text-[28px] leading-none mt-3 text-[hsl(var(--warning))]">
{fmt.usd(total.adjustments)}
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<FilterChips
options={STATUS_OPTIONS}
value={status}
@@ -198,15 +185,6 @@ export function Remittances() {
}}
eyebrow="Status"
/>
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
@@ -251,12 +229,6 @@ export function Remittances() {
className={cn(
"animate-row-flash",
isSelected && [
// Same accent-tinted highlight as Acks:
// background tint + inset ring + left
// accent strip, layered on top of the
// TableRow primitive's
// `data-[state=selected]:bg-muted` to
// win visually.
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
@@ -283,28 +255,28 @@ export function Remittances() {
)
) : null}
</TableCell>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
<TableCell className="display mono text-[12.5px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
<TableCell className="text-right display mono">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
<TableCell className="text-right display mono text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
{isOpen && hasAdjustments ? (
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/30 hover:bg-muted/30"
className="bg-muted/20 hover:bg-muted/20"
>
<TableCell />
<TableCell colSpan={7} className="py-3">
@@ -314,7 +286,7 @@ export function Remittances() {
strokeWidth={1.5}
aria-hidden
/>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<div className="eyebrow">
Adjustments ({r.adjustments!.length})
</div>
</div>
+112 -171
View File
@@ -11,13 +11,7 @@ import {
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 { PageHeader } from "@/components/PageHeader";
import {
Select,
SelectContent,
@@ -72,10 +66,6 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
// ---------------------------------------------------------------------------
// Small reusable card bits
// ---------------------------------------------------------------------------
function ValidationDot({
passed,
hasWarnings,
@@ -86,7 +76,7 @@ function ValidationDot({
if (passed) {
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--success))] font-medium"
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--success))] font-medium"
title="Validation passed"
>
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
@@ -97,7 +87,7 @@ function ValidationDot({
if (hasWarnings) {
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--warning))] font-medium"
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--warning))] font-medium"
title="Validation passed with warnings"
>
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
@@ -107,7 +97,7 @@ function ValidationDot({
}
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-destructive font-medium"
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-destructive font-medium"
title="Validation failed"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
@@ -126,14 +116,12 @@ function StatPill({
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="flex flex-col gap-1">
<div className="eyebrow">{label}</div>
<div
className={cn(
"text-[13px] text-foreground",
mono && "display num"
"text-[12.5px] text-foreground",
mono && "display mono"
)}
>
{value}
@@ -147,7 +135,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<div className="surface-2 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
@@ -164,7 +152,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
/>
<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="display mono text-[12.5px]">{claim.claim_id}</span>
<span className="text-[12px] text-muted-foreground truncate">
{claim.subscriber.first_name} {claim.subscriber.last_name}
</span>
@@ -172,23 +160,19 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
· {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>
<div className="mono text-[10.5px] text-muted-foreground flex items-center gap-2 mt-1">
<span>NPI {claim.billing_provider.npi}</span>
<span className="opacity-40">·</span>
<span>
{claim.service_lines.length} line
{claim.service_lines.length === 1 ? "" : "s"}
</span>
<span>·</span>
<span>
{claim.diagnoses.length} dx
</span>
<span className="opacity-40">·</span>
<span>{claim.diagnoses.length} dx</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px]">
<div className="display mono text-[14px]">
{fmt.usdDecimal(claim.claim.total_charge)}
</div>
<div className="mt-0.5">
@@ -198,7 +182,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
</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="border-t border-border/30 px-4 py-3 grid gap-4 bg-muted/15 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill
@@ -214,9 +198,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
{claim.diagnoses.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Diagnoses
</div>
<div className="eyebrow mb-1.5">Diagnoses</div>
<div className="flex flex-wrap gap-1.5">
{claim.diagnoses.map((d, i) => (
<Badge key={`${d.code}-${i}`} variant="muted">
@@ -230,13 +212,11 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
{claim.service_lines.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Service lines
</div>
<div className="eyebrow mb-1.5">Service lines</div>
<div className="rounded-md border border-border/40 overflow-x-auto">
<table className="w-full text-[12px]">
<thead className="bg-muted/30">
<tr className="text-left text-muted-foreground">
<thead className="bg-muted/20 text-muted-foreground">
<tr className="text-left">
<th className="px-2.5 py-1.5 font-medium">#</th>
<th className="px-2.5 py-1.5 font-medium">Code</th>
<th className="px-2.5 py-1.5 font-medium">Mods</th>
@@ -258,9 +238,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
{(claim.validation.errors.length > 0 ||
claim.validation.warnings.length > 0) ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Validation
</div>
<div className="eyebrow mb-1.5">Validation</div>
<ul className="space-y-1 text-[12px]">
{claim.validation.errors.map((issue, i) => (
<li
@@ -272,7 +250,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
strokeWidth={1.75}
/>
<span>
<span className="num">{issue.rule}</span> {issue.message}
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
@@ -286,7 +264,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
strokeWidth={1.75}
/>
<span>
<span className="num">{issue.rule}</span> {issue.message}
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
@@ -302,22 +280,22 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
function ServiceLine837Row({ line }: { line: ServiceLine }) {
return (
<tr className="border-t border-border/30">
<td className="px-2.5 py-1.5 num">{line.line_number}</td>
<td className="px-2.5 py-1.5 num">
<td className="px-2.5 py-1.5 mono">{line.line_number}</td>
<td className="px-2.5 py-1.5 mono">
{line.procedure.qualifier}·{line.procedure.code}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{line.procedure.modifiers.length > 0
? line.procedure.modifiers.join(", ")
: "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{line.service_date ?? "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
</td>
<td className="px-2.5 py-1.5 num text-right display">
<td className="px-2.5 py-1.5 mono text-right display">
{fmt.usdDecimal(line.charge)}
</td>
</tr>
@@ -326,12 +304,9 @@ function ServiceLine837Row({ line }: { line: ServiceLine }) {
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
const [open, setOpen] = useState(false);
const passed = claim.service_payments.length > 0; // placeholder; the
// 835 batch-level validation is on summary. We treat the per-card view as
// "ok" if the claim parsed at all. The card-level dot still gives a visual
// anchor; the summary toast shows the real pass/fail.
const passed = claim.service_payments.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<div className="surface-2 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
@@ -348,32 +323,30 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">
<span className="display mono text-[12.5px]">
{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">
<div className="mono text-[10.5px] text-muted-foreground flex items-center gap-2 mt-1">
<span>CLP {claim.status_code}</span>
{claim.original_claim_id ? (
<>
<span>·</span>
<span className="opacity-40">·</span>
<span>Orig {claim.original_claim_id}</span>
</>
) : null}
<span>·</span>
<span>
{claim.service_payments.length} svc
</span>
<span className="opacity-40">·</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))]">
<div className="display mono text-[14px] text-[hsl(var(--success))]">
{fmt.usdDecimal(claim.total_paid)}
</div>
<div className="text-[11px] text-muted-foreground num">
<div className="mono text-[10.5px] text-muted-foreground">
of {fmt.usdDecimal(claim.total_charge)}
</div>
<div className="mt-0.5">
@@ -383,7 +356,7 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
</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="border-t border-border/30 px-4 py-3 grid gap-4 bg-muted/15 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Filing" value={claim.claim_filing_indicator ?? "—"} />
<StatPill label="Facility" value={claim.facility_type ?? "—"} />
@@ -400,13 +373,11 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
{claim.service_payments.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Service payments
</div>
<div className="eyebrow mb-1.5">Service payments</div>
<div className="rounded-md border border-border/40 overflow-x-auto">
<table className="w-full text-[12px]">
<thead className="bg-muted/30">
<tr className="text-left text-muted-foreground">
<thead className="bg-muted/20 text-muted-foreground">
<tr className="text-left">
<th className="px-2.5 py-1.5 font-medium">#</th>
<th className="px-2.5 py-1.5 font-medium">Code</th>
<th className="px-2.5 py-1.5 font-medium">Mods</th>
@@ -434,23 +405,23 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
function ServicePaymentRow({ svc }: { svc: ServicePayment }) {
return (
<tr className="border-t border-border/30">
<td className="px-2.5 py-1.5 num">{svc.line_number}</td>
<td className="px-2.5 py-1.5 num">
<td className="px-2.5 py-1.5 mono">{svc.line_number}</td>
<td className="px-2.5 py-1.5 mono">
{svc.procedure_qualifier}·{svc.procedure_code}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{svc.modifiers.length > 0 ? svc.modifiers.join(", ") : "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{svc.service_date ?? "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{svc.units ? `${toNum(svc.units)} ${svc.unit_type ?? ""}`.trim() : "—"}
</td>
<td className="px-2.5 py-1.5 num text-right display">
<td className="px-2.5 py-1.5 mono text-right display">
{fmt.usdDecimal(svc.charge)}
</td>
<td className="px-2.5 py-1.5 num text-right display text-[hsl(var(--success))]">
<td className="px-2.5 py-1.5 mono text-right display text-[hsl(var(--success))]">
{fmt.usdDecimal(svc.payment)}
</td>
</tr>
@@ -472,11 +443,9 @@ export function Upload() {
passed: 0,
failed: 0,
});
const [dragging, setDragging] = useState(false);
const addParsedBatch = useAppStore((s) => s.addParsedBatch);
const parsedBatches = useAppStore((s) => s.parsedBatches);
// useParse wires the mutation; on success it invalidates every list query
// (batches, claims, remittances, providers, activity) so the other pages
// see the new data without manual refetching.
const parseMutation = useParse(kind);
const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835;
@@ -525,20 +494,14 @@ export function Upload() {
failed: event.data.failed,
}));
}
// financial_info / trace / payer / payee are accepted silently;
// the page summary at the end reports the real pass/fail counts.
};
try {
// mutateAsync triggers the mutation; the streaming callbacks above
// populate `stream` as claims arrive, and the resolver returns the
// final BatchSummary from the "summary" NDJSON line.
const summary = await parseMutation.mutateAsync({
file,
options: { payer, onProgress },
});
// The final return value is a BatchSummary in the streaming case.
const finalSummary =
summary && typeof summary === "object" && "total_claims" in summary
? summary
@@ -579,44 +542,23 @@ export function Upload() {
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) pickFile(f);
}
return (
<div className="space-y-6 md: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] sm:text-[26px] 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>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Upload · EDI parser"
title={<>Parse an <span className="display italic text-muted-foreground">X12</span> file</>}
subtitle="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."
/>
<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="surface-2 rounded-xl p-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Kind
</div>
<div className="eyebrow">Kind</div>
<Select
value={kind}
onValueChange={(v) => {
@@ -637,9 +579,7 @@ export function Upload() {
</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>
<div className="eyebrow">Payer config</div>
<Select value={payer} onValueChange={setPayer}>
<SelectTrigger>
<SelectValue />
@@ -656,7 +596,11 @@ export function Upload() {
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
@@ -665,33 +609,42 @@ export function Upload() {
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-10 sm:py-8 min-h-[140px] sm:min-h-[120px] cursor-pointer transition-colors",
"hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
"px-6 py-12 min-h-[160px] cursor-pointer",
dragging
? "border-accent bg-accent/5 ring-2 ring-accent/30"
: "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<UploadIcon
className="h-5 w-5 text-muted-foreground"
strokeWidth={1.5}
/>
{/* Soft inner glow when dragging — a precision-instrument
"active" state. */}
<div
className={cn(
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
)}
>
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
{file ? (
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 text-[13px]">
<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">
<span className="mono text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="text-sm font-medium">
Drop a file here, or click to choose
<div className="text-[13.5px] font-medium">
{dragging ? "Release to upload" : "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 className="mono text-[11px] text-muted-foreground">
.txt the X12 837/835 file as exported from your clearinghouse
</div>
</>
)}
@@ -704,8 +657,8 @@ export function Upload() {
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-[12px] text-muted-foreground">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mt-4 pt-4 border-t border-border/30">
<div className="mono text-[11px] 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))]" />
@@ -751,15 +704,13 @@ export function Upload() {
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
{(running || stream.items.length > 0) ? (
<Card>
<CardHeader className="pb-3">
<div className="surface-2 rounded-xl p-5">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-[14px] flex items-center gap-2">
<div className="flex items-center gap-2 text-[13.5px] font-medium">
{running ? (
<Loader2
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
@@ -772,22 +723,19 @@ export function Upload() {
/>
)}
{kind === "837p" ? "Claims" : "Claim payments"}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
{totalSoFar} of{" "}
{expectedTotal ?? "?"} parsed
</div>
<p className="mono text-[10.5px] text-muted-foreground mt-1">
{totalSoFar} of {expectedTotal ?? "?"} parsed
{stream.failed > 0
? ` · ${stream.failed} failed validation`
: ""}
</p>
</div>
<div className="text-right">
<div className="display num text-[16px]">
<div className="display mono text-[16px]">
{expectedTotal ? `${progressPct}%` : "…"}
</div>
<div className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
progress
</div>
<div className="eyebrow">progress</div>
</div>
</div>
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
@@ -796,11 +744,10 @@ export function Upload() {
style={{ width: `${progressPct}%` }}
/>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
<div className="mt-4 max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
{stream.items.length === 0 && running ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<div className="text-center py-8 text-muted-foreground text-[12.5px]">
Waiting for first claim
</div>
) : null}
@@ -815,20 +762,15 @@ export function Upload() {
)
)}
</div>
</CardContent>
</Card>
</div>
) : 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. Click a row to revisit it (in a
future view).
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow mb-3">Recent batches</div>
<p className="text-[12px] text-muted-foreground -mt-2 mb-3">
Parsed files, newest first.
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border/40">
{parsedBatches.map((b) => (
<li
@@ -839,26 +781,25 @@ export function Upload() {
{b.kind === "837p" ? "837P" : "835"}
</Badge>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
<div className="text-[13px] font-medium truncate">
{b.inputFilename}
</div>
<div className="text-[11px] text-muted-foreground num">
<div className="mono text-[10.5px] text-muted-foreground">
{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]">
<div className="display mono text-[12.5px]">
{b.passed} / {b.claimCount}
</div>
<div className="text-[11px] text-muted-foreground">
<div className="mono text-[10.5px] text-muted-foreground">
passed
</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
) : null}
</div>
);
+36 -7
View File
@@ -13,24 +13,29 @@ export default {
extend: {
fontFamily: {
sans: [
"Cabinet Grotesk",
"Geist",
"ui-sans-serif",
"-apple-system",
"BlinkMacSystemFont",
"SF Pro Text",
"Segoe UI",
"system-ui",
"Roboto",
"sans-serif",
],
mono: [
"Geist Mono",
"SF Mono",
"ui-monospace",
"SF Mono",
"Menlo",
"Monaco",
"Consolas",
"monospace",
],
display: [
"Instrument Serif",
"Iowan Old Style",
"Apple Garamond",
"Baskerville",
"serif",
],
},
colors: {
border: "hsl(var(--border))",
@@ -66,6 +71,10 @@ export default {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
signal: {
DEFAULT: "hsl(var(--signal))",
foreground: "hsl(var(--warning-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
@@ -83,8 +92,8 @@ export default {
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 4px)",
sm: "calc(var(--radius) - 6px)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xl: "calc(var(--radius) + 4px)",
},
keyframes: {
@@ -104,6 +113,14 @@ export default {
from: { opacity: "0" },
to: { opacity: "1" },
},
"fade-in-up": {
from: { opacity: "0", transform: "translateY(12px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"slide-in-right": {
from: { opacity: "0", transform: "translateX(16px)" },
to: { opacity: "1", transform: "translateX(0)" },
},
shimmer: {
"0%": { transform: "translateX(-100%)" },
"100%": { transform: "translateX(100%)" },
@@ -117,15 +134,27 @@ export default {
"0%": { backgroundColor: "hsl(var(--accent) / 0.10)" },
"100%": { backgroundColor: "transparent" },
},
"pulse-dot": {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.4" },
},
"ticker-blink": {
"0%, 60%": { opacity: "1" },
"61%, 100%": { opacity: "0.3" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"fade-in": "fade-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both",
"fade-in-soft": "fade-in-soft 0.6s ease-out both",
"fade-in-up": "fade-in-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both",
"slide-in-right": "slide-in-right 0.35s cubic-bezier(0.2, 0.8, 0.2, 1) both",
shimmer: "shimmer 2.4s linear infinite",
scan: "scan 1.6s ease-in-out infinite",
"row-flash": "row-flash 1.2s ease-out",
"pulse-dot": "pulse-dot 1.8s ease-in-out infinite",
"ticker-blink": "ticker-blink 2s ease-in-out infinite",
},
},
},