feat: the denials desk, and Ava working where you can watch her
The operator console for Northlake Health's Denials Investigator (dev, org 900000109 / app 900000102). A revenue-cycle manager asks why claim denials rose; Ava — an AI employee — investigates the hospital's Databricks lakehouse and comes back with a cause, a dollar figure and a recommendation. He approves, and she re-checks on her own two minutes later. THE POINT OF THE SCREEN is that her work is checkable. Anyone can be told an agent analysed their denials; AvaLive shows the actual SQL she sent to Databricks, in order, with what came back and how long each query took — and it does NOT hide a query she got wrong and corrected, because that is the most convincing thing on the page. Two decisions worth knowing about: * Her trace does not come from the AI-employee monitor API. That endpoint needs an org-scoped studio token and answers "no organization scope on this account" for every user of this console. It comes instead from two views (northlake_app.ava_trace / ava_decisions) exposed as ordinary rdbms record views, behind a login role that cannot read the aiemployee schema at all. Seeded by sm2/custom-apps/denials-investigator/07_console_views.sql. * Ava's own activities — Record Findings, Confirm Recovery, Flag Still Elevated, Escalate — are deliberately absent from STAGE_ACTIONS. Putting them on screen would only let a person sign in as the agent and press its button, which is a person impersonating an agent rather than an agent working. Violet is the AI and nothing else is violet; amber is the one queue a human owns. No API URL is compiled in — config.js carries it at placement time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
1c1c120202
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# Local development only (`npm run dev`). Nothing here is used by a deployed
|
||||
# build — those values come from config.js, which the server writes when it
|
||||
# places the artifact. Do not commit environment-specific values: one build is
|
||||
# promoted across environments unchanged.
|
||||
VITE_ZINO_API_URL=https://dev.getzino.in
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
.vite
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
npm-debug.log*
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
41
README.md
Normal file
41
README.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Northlake Health — Denials Desk
|
||||
|
||||
The operator console for the **Denials Investigator** app (dev, org `900000109`
|
||||
/ app `900000102`). A revenue-cycle manager asks why claim denials rose; an AI
|
||||
employee — **Ava** — investigates the hospital's Databricks lakehouse and comes
|
||||
back with a cause, a dollar figure and a recommendation. The manager approves.
|
||||
|
||||
This repo is **outside sm2** on purpose: it is a frontgen artifact. A push to
|
||||
`main` triggers a build, and the artifact is placed at
|
||||
`https://preview-dev.getzino.in/northlake-health/`.
|
||||
|
||||
## What is where
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `src/api/config.ts` | Every id: app, workflow, activities, record views, stages, per-stage actions |
|
||||
| `src/api/client.ts` | The gateway client. App-scoped routes plus the two rdbms views that carry Ava's trace |
|
||||
| `src/screens/QueueScreen.tsx` | A queue — the record view filtered on `current_state_name` |
|
||||
| `src/screens/CaseScreen.tsx` | One investigation: findings, recommendations, Ava's live work, the actions |
|
||||
| `src/screens/NewInvestigationScreen.tsx` | Ask a question — the INIT activity's form, read live from view-service |
|
||||
| `src/components/AvaLive.tsx` | **The demo.** Every query Ava runs, as she runs it |
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5174 — talks to dev.getzino.in via .env
|
||||
```
|
||||
|
||||
Sign in as `dan.reyes@northlake.example`. The org is fixed in `config.ts`.
|
||||
|
||||
## Two things that will bite
|
||||
|
||||
1. **No API URL is compiled in.** `config.js`, written by the server when it
|
||||
places the build, carries `VITE_ZINO_API_URL`. A production build with no
|
||||
`config.js` fails loudly rather than calling whichever backend built it.
|
||||
2. **Ava's trace does not come from the monitor API.** That endpoint needs an
|
||||
org-scoped studio token; an app sign-in gets "no organization scope on this
|
||||
account". It comes from two views (`northlake_app.ava_trace`,
|
||||
`ava_decisions`) exposed as ordinary rdbms record views. Seeded by
|
||||
`sm2/custom-apps/denials-investigator/07_console_views.sql`.
|
||||
49
index.html
Normal file
49
index.html
Normal file
@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!--BASE_HREF-->
|
||||
<title>Northlake Health | Denials Desk</title>
|
||||
<!-- Inline SVG data-URI favicon: no asset to resolve, which matters because
|
||||
this build is promoted between environments unchanged and a
|
||||
/favicon.ico would resolve against whatever mount path the server
|
||||
chose. The mark is the one in src/components/NorthlakeLogo.tsx — a
|
||||
teal chevron over a pulse line. Change one, change the other. -->
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' rx='9' fill='%230E7C86'/%3E%3Cpath d='M8 25h6l3-7 4 12 3-8h8' fill='none' stroke='%23fff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<!-- Theme, resolved BEFORE first paint. This duplicates a few lines of
|
||||
src/theme.ts on purpose: the bundle is a module and therefore deferred,
|
||||
so resolving the theme in React means the browser paints light first
|
||||
and a dark-mode user gets a white flash on every navigation. Blocking
|
||||
and inline is the only place this can run early enough. Keep the
|
||||
localStorage key in step with THEME_KEY. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('northlake-denials-theme');
|
||||
if (t !== 'light' && t !== 'dark') {
|
||||
t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
if (t === 'dark') {
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.setAttribute('content', '#10233a');
|
||||
}
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="./config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2419
package-lock.json
generated
Normal file
2419
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "northlake-health",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Northlake Health — Denials Desk. An AI analyst investigates why claim denials rose; a revenue-cycle manager decides what to do about it.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
35
src/App.tsx
Normal file
35
src/App.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { useZino } from './api/provider';
|
||||
import { Shell } from './layout/Shell';
|
||||
import { Login } from './pages/Login';
|
||||
import { QueueScreen } from './screens/QueueScreen';
|
||||
import { CaseScreen } from './screens/CaseScreen';
|
||||
import { NewInvestigationScreen } from './screens/NewInvestigationScreen';
|
||||
|
||||
/**
|
||||
* Routes.
|
||||
*
|
||||
* The signed-out case swaps the WHOLE tree for the sign-in page rather than
|
||||
* redirecting, so there is no window in which a protected screen mounts, fires
|
||||
* a request and gets a 401 back. The client's auth-error handler clears the
|
||||
* user, which lands here and re-renders as Login — one path in, one path out.
|
||||
*/
|
||||
export function App() {
|
||||
const { user } = useZino();
|
||||
|
||||
if (!user) return <Login />;
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Shell />}>
|
||||
<Route path="/q/:q" element={<QueueScreen />} />
|
||||
<Route path="/c/:id" element={<CaseScreen />} />
|
||||
<Route path="/new" element={<NewInvestigationScreen />} />
|
||||
{/* Awaiting Review is the landing page, not "all cases": it is the
|
||||
queue with work in it, and the one the console exists to serve. */}
|
||||
<Route path="/" element={<Navigate to="/q/review" replace />} />
|
||||
<Route path="*" element={<Navigate to="/q/review" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
263
src/api/client.ts
Normal file
263
src/api/client.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import type {
|
||||
ActivityResult,
|
||||
ApiError,
|
||||
AuditEntry,
|
||||
AvaDecision,
|
||||
AvaTraceStep,
|
||||
FormScreenResponse,
|
||||
LoginResponse,
|
||||
RecordViewParams,
|
||||
RecordViewResponse,
|
||||
Row,
|
||||
User,
|
||||
} from './types';
|
||||
import { APP_ID, DECISIONS_VIEW, TRACE_VIEW } from './config';
|
||||
|
||||
const TOKEN_KEY = 'northlake_denials_token';
|
||||
|
||||
/**
|
||||
* HTTP client for the Zino gateway.
|
||||
*
|
||||
* Most routes are app-scoped (`/app/900000102/...`); login is not. The JWT
|
||||
* persists in localStorage so a refresh does not bounce the manager back to the
|
||||
* sign-in screen mid-review.
|
||||
*/
|
||||
export class ZinoClient {
|
||||
readonly baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private onAuthError?: () => void;
|
||||
|
||||
constructor(baseUrl: string, onAuthError?: () => void) {
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.onAuthError = onAuthError;
|
||||
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
setAuthErrorHandler(fn: () => void): void {
|
||||
this.onAuthError = fn;
|
||||
}
|
||||
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
this.setToken(null);
|
||||
this.onAuthError?.();
|
||||
throw { status: 401, message: 'Session expired — sign in again' } as ApiError;
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const j = (await res.json()) as { error?: string; message?: string };
|
||||
message = j.error || j.message || message;
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw { status: res.status, message } as ApiError;
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// --- Auth ---------------------------------------------------------------
|
||||
|
||||
/** org_id must be a STRING. The gateway rejects a number with
|
||||
* "cannot unmarshal number into Go struct field LoginRequest.org_id". */
|
||||
async login(email: string, password: string, orgId: string): Promise<LoginResponse> {
|
||||
const res = await this.request<LoginResponse>('POST', '/usr/login', {
|
||||
email,
|
||||
password,
|
||||
org_id: String(orgId),
|
||||
});
|
||||
this.setToken(res.token);
|
||||
return res;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.setToken(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user, from the server.
|
||||
*
|
||||
* Needed because **the JWT does not carry roles** — its claims are only
|
||||
* user_id / name / email / sub / exp / iat. `currentUser()` can restore an
|
||||
* identity across a refresh but never a permission set; this endpoint and the
|
||||
* login response body are the only places roles come from.
|
||||
*/
|
||||
me(): Promise<User> {
|
||||
return this.request<User>('GET', '/usr/me');
|
||||
}
|
||||
|
||||
/** Decode the persisted JWT into a User — no round trip, so a hard refresh
|
||||
* restores the session without flashing the sign-in screen. The token has NO
|
||||
* roles claim, so `roles` is always empty here; the provider follows up with
|
||||
* `me()`. Anything that gates on roles must tolerate that gap. */
|
||||
currentUser(): User | null {
|
||||
if (!this.token) return null;
|
||||
try {
|
||||
const p = JSON.parse(atob(this.token.split('.')[1]));
|
||||
return {
|
||||
id: String(p.user_id ?? p.sub ?? ''),
|
||||
org_id: String(p.org_id ?? ''),
|
||||
name: p.name ?? '',
|
||||
email: p.email ?? '',
|
||||
roles: p.roles ?? [],
|
||||
groups: p.groups ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Views --------------------------------------------------------------
|
||||
|
||||
async recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
|
||||
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
||||
rv_template_uid: rvUid,
|
||||
search_query: {
|
||||
page: params.page ?? 1,
|
||||
limit: params.limit ?? 50,
|
||||
sort_by: params.sortBy ?? '',
|
||||
sort_dir: params.sortDir ?? 'desc',
|
||||
search: params.search ?? '',
|
||||
filters: (params.filters ?? []).map((f) => ({
|
||||
field_key: f.field_key,
|
||||
value: f.value,
|
||||
value2: '',
|
||||
data_type: f.data_type ?? 'string',
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Rows only. The response key differs by source type — workflow views
|
||||
* return `data`, rdbms views return `records`. */
|
||||
async rows(rvUid: string, params: RecordViewParams = {}): Promise<Row[]> {
|
||||
const r = await this.recordView(rvUid, params);
|
||||
return r.data ?? r.records ?? [];
|
||||
}
|
||||
|
||||
async audit(instanceId: number | string): Promise<AuditEntry[]> {
|
||||
const rows = await this.request<AuditEntry[]>(
|
||||
'GET',
|
||||
`/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`,
|
||||
);
|
||||
// One submission writes several audit rows — a dispatch record, a
|
||||
// trigger-pipeline record, then the commit. Only the commit is the event a
|
||||
// person means by "what happened"; the rest is engine bookkeeping, kept in
|
||||
// the DB for reconciliation and deliberately not shown.
|
||||
return (rows ?? []).filter((r) => !String(r.execution_state ?? '').startsWith('TRIGGER_'));
|
||||
}
|
||||
|
||||
// --- Ava's own work -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every step Ava took on a case, oldest first.
|
||||
*
|
||||
* NOT from `/monitor/instances/:id/trace` — that needs an org-scoped studio
|
||||
* token and answers "no organization scope on this account" for every user of
|
||||
* this console. These come from `northlake_app.ava_trace`, a view over
|
||||
* `aiemployee.tbl_ai_trace_events` exposed as an ordinary rdbms record view
|
||||
* (see 07_console_views.sql). The role behind it cannot read the aiemployee
|
||||
* schema at all — it sees these columns and nothing else.
|
||||
*/
|
||||
async avaTrace(instanceId: number | string): Promise<AvaTraceStep[]> {
|
||||
const rows = await this.rows(TRACE_VIEW, {
|
||||
limit: 200,
|
||||
filters: [{ field_key: 'instance_id', value: String(instanceId) }],
|
||||
});
|
||||
return (rows as unknown as AvaTraceStep[])
|
||||
.slice()
|
||||
.sort((a, b) => String(a.started_at ?? '').localeCompare(String(b.started_at ?? '')));
|
||||
}
|
||||
|
||||
/** What Ava submitted on a case — confidence, cost, model, reasoning. */
|
||||
async avaDecisions(instanceId: number | string): Promise<AvaDecision[]> {
|
||||
const rows = await this.rows(DECISIONS_VIEW, {
|
||||
limit: 50,
|
||||
filters: [{ field_key: 'instance_id', value: String(instanceId) }],
|
||||
});
|
||||
return (rows as unknown as AvaDecision[])
|
||||
.slice()
|
||||
.sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')));
|
||||
}
|
||||
|
||||
// --- Forms --------------------------------------------------------------
|
||||
|
||||
/** The live definition of an activity's form: fields, types, select options,
|
||||
* which are mandatory. Read at open time rather than hardcoded, so a field
|
||||
* added in Studio appears here without a frontend change. */
|
||||
formSchema(activityUid: string, instanceId?: number | string): Promise<FormScreenResponse> {
|
||||
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
|
||||
activity_id: activityUid,
|
||||
device_type: 'desktop',
|
||||
...(instanceId != null ? { instance_id: numericId(instanceId) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Workflow execution -------------------------------------------------
|
||||
|
||||
/** Start a new investigation (the INIT activity). */
|
||||
startInstance(
|
||||
workflowUuid: string,
|
||||
activityUid: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ActivityResult> {
|
||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
|
||||
workflow_uuid: workflowUuid,
|
||||
activity_id: activityUid,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** Perform an activity on an existing case. The workflow refuses anything the
|
||||
* current state does not allow — that refusal is a feature, not an error to
|
||||
* paper over. */
|
||||
performActivity(
|
||||
workflowUuid: string,
|
||||
instanceId: number | string,
|
||||
activityUid: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ActivityResult> {
|
||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
|
||||
workflow_uuid: workflowUuid,
|
||||
instance_id: numericId(instanceId),
|
||||
activity_id: activityUid,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance ids must go over the wire as JSON NUMBERS.
|
||||
*
|
||||
* They arrive from route params as strings, and view-service decodes
|
||||
* `instance_id` into an int64 — so a quoted "58288" fails the decode and the
|
||||
* whole request comes back "Invalid request body" with no clue which field was
|
||||
* at fault. A non-numeric id is passed through untouched rather than becoming
|
||||
* NaN: if an id ever stops being an integer, the server should say so plainly.
|
||||
*/
|
||||
function numericId(id: number | string): number | string {
|
||||
if (typeof id === 'number') return id;
|
||||
const n = Number(id);
|
||||
return Number.isFinite(n) && id.trim() !== '' ? n : id;
|
||||
}
|
||||
189
src/api/config.ts
Normal file
189
src/api/config.ts
Normal file
@ -0,0 +1,189 @@
|
||||
// Northlake Health — Denials Desk. dev, org 900000109 / app 900000102.
|
||||
//
|
||||
// Every id here is a readable uid, not a UUID: this app's config was seeded
|
||||
// straight into the builder tables (sm2/custom-apps/denials-investigator/), so
|
||||
// the uids ARE the handles the gateway resolves.
|
||||
|
||||
export const ORG_ID = '900000109';
|
||||
export const APP_ID = '900000102';
|
||||
|
||||
/** One workflow. One instance per investigation. */
|
||||
export const WORKFLOW = 'dni_wf_investigation';
|
||||
|
||||
export const RECORD_VIEW = 'dni-rv-cases';
|
||||
|
||||
/**
|
||||
* Ava's own work, read through an rdbms bridge rather than the AI-employee
|
||||
* monitor endpoints.
|
||||
*
|
||||
* The monitor API (`/monitor/instances/:id/trace`) needs an ORG-SCOPED studio
|
||||
* token; an app sign-in answers "no organization scope on this account". So
|
||||
* two views in a `northlake_app` schema expose exactly the columns below to a
|
||||
* login role that is denied the aiemployee schema itself, and the platform DB
|
||||
* is registered as an ordinary org connection. Seeded by 07_console_views.sql.
|
||||
*/
|
||||
export const TRACE_VIEW = 'dni-rv-ava-trace';
|
||||
export const DECISIONS_VIEW = 'dni-rv-ava-decisions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activities — the uid is the contract with the workflow. Forms are NOT
|
||||
// hardcoded: each activity's fields are read from view-service at open time.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const ACT = {
|
||||
START: 'dni-act-start', // INIT
|
||||
FINDINGS: 'dni-act-findings', // Ava
|
||||
APPROVE: 'dni-act-approve',
|
||||
CHANGES: 'dni-act-changes',
|
||||
REJECT: 'dni-act-reject',
|
||||
RECHECK_DUE: 'dni-act-recheck-due', // scheduled
|
||||
CONFIRM: 'dni-act-confirm', // Ava
|
||||
FLAG: 'dni-act-flag', // Ava
|
||||
ESCALATE: 'dni-act-escalate', // Ava
|
||||
RESUME: 'dni-act-resume',
|
||||
CLOSE: 'dni-act-close',
|
||||
} as const;
|
||||
|
||||
/** Mirrors workflow.tbl_wf_states. Keep in step. */
|
||||
export const STAGES = [
|
||||
'Investigating',
|
||||
'Awaiting Review',
|
||||
'Approved',
|
||||
'Monitoring',
|
||||
'Closed',
|
||||
] as const;
|
||||
|
||||
export type Stage = (typeof STAGES)[number];
|
||||
|
||||
/** Off the happy path, and the only state a person is *required* to touch. */
|
||||
export const NEEDS_HUMAN = 'Needs Human';
|
||||
|
||||
export function stageStep(stateName?: string): number {
|
||||
if (!stateName) return -1;
|
||||
return STAGES.indexOf(stateName as Stage);
|
||||
}
|
||||
|
||||
export type Tone = 'slate' | 'amber' | 'sky' | 'emerald' | 'rose' | 'violet';
|
||||
|
||||
export const STAGE_TONE: Record<string, Tone> = {
|
||||
// Violet is the AI's colour throughout this console, and Investigating is
|
||||
// the state Ava owns — so the queue itself says who is holding the case.
|
||||
Investigating: 'violet',
|
||||
// The one amber: the pile a human owns. Nothing else in the pipeline is
|
||||
// amber, so it reads as "needs you" at a glance.
|
||||
'Awaiting Review': 'amber',
|
||||
Approved: 'sky',
|
||||
Monitoring: 'violet',
|
||||
'Needs Human': 'rose',
|
||||
Closed: 'emerald',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queues. The navigation IS the pipeline: each is the record view filtered on
|
||||
// current_state_name. `stage: null` means no filter.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface Queue {
|
||||
path: string;
|
||||
label: string;
|
||||
stage: string | null;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const QUEUES: Queue[] = [
|
||||
{ path: 'review', label: 'Awaiting Review', stage: 'Awaiting Review', hint: 'Findings on your desk' },
|
||||
{ path: 'investigating', label: 'Investigating', stage: 'Investigating', hint: 'Ava is working' },
|
||||
{ path: 'monitoring', label: 'Monitoring', stage: 'Monitoring', hint: 'Waiting on the re-check' },
|
||||
{ path: 'needs-human', label: 'Needs Human', stage: NEEDS_HUMAN, hint: 'Ava could not finish' },
|
||||
{ path: 'closed', label: 'Closed', stage: 'Closed', hint: 'Done, with an outcome' },
|
||||
{ path: 'all', label: 'All Investigations', stage: null, hint: 'Every case' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions available per stage.
|
||||
//
|
||||
// Mirrors workflow.tbl_wf_state_allowed_activities MINUS the activities Ava
|
||||
// performs. Record Findings, Confirm Recovery, Flag Still Elevated and
|
||||
// Escalate are hers: she is woken by the workflow and submits them under her
|
||||
// own JWT. Putting them on screen would only let a person sign in as the agent
|
||||
// and press its button, which is a person impersonating an agent rather than an
|
||||
// agent working. If it is not on this list, the only thing that can perform it
|
||||
// is the thing that is supposed to.
|
||||
//
|
||||
// This enforces nothing — the workflow re-checks RBAC on every submission.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface Action {
|
||||
activity: string;
|
||||
label: string;
|
||||
roles: string[];
|
||||
kind: 'primary' | 'secondary' | 'danger';
|
||||
/** Shown under the button, so a reviewer knows what it sets in motion. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const STAGE_ACTIONS: Record<string, Action[]> = {
|
||||
'Awaiting Review': [
|
||||
{
|
||||
activity: ACT.APPROVE,
|
||||
label: 'Approve Actions',
|
||||
roles: ['revenue_cycle_manager'],
|
||||
kind: 'primary',
|
||||
hint: 'Books the re-check; Ava reports back on whether it worked',
|
||||
},
|
||||
{
|
||||
activity: ACT.CHANGES,
|
||||
label: 'Send Back to Ava',
|
||||
roles: ['revenue_cycle_manager'],
|
||||
kind: 'secondary',
|
||||
hint: 'She re-runs the investigation with your note',
|
||||
},
|
||||
{ activity: ACT.REJECT, label: 'Reject', roles: ['revenue_cycle_manager'], kind: 'danger' },
|
||||
],
|
||||
Approved: [
|
||||
{ activity: ACT.CLOSE, label: 'Close Case', roles: ['revenue_cycle_manager'], kind: 'secondary' },
|
||||
],
|
||||
'Needs Human': [
|
||||
{
|
||||
activity: ACT.RESUME,
|
||||
label: 'Resume Investigation',
|
||||
roles: ['revenue_cycle_manager'],
|
||||
kind: 'primary',
|
||||
hint: 'Send it back to Ava with guidance',
|
||||
},
|
||||
{ activity: ACT.CLOSE, label: 'Close Case', roles: ['revenue_cycle_manager'], kind: 'secondary' },
|
||||
],
|
||||
};
|
||||
|
||||
export const SUPER_ROLES = ['Super Admin', 'Admin'];
|
||||
|
||||
/**
|
||||
* Which actions to offer.
|
||||
*
|
||||
* FAILS OPEN, deliberately. The JWT carries no roles claim, so there is a real
|
||||
* window — a restored session before `/usr/me` answers — where the console does
|
||||
* not know what the user may do. Filtering on an empty role list in that window
|
||||
* hides every button and makes a working app look broken. With no roles known,
|
||||
* show what the stage allows and let the server refuse what it must; that
|
||||
* refusal is legible, a missing button is not.
|
||||
*/
|
||||
export function actionsFor(stage: string | undefined, roles: string[] | undefined): Action[] {
|
||||
const all = STAGE_ACTIONS[stage ?? ''] ?? [];
|
||||
const mine = roles ?? [];
|
||||
if (mine.length === 0) return all;
|
||||
if (mine.some((r) => SUPER_ROLES.includes(r))) return all;
|
||||
return all.filter((a) => a.roles.some((r) => mine.includes(r)));
|
||||
}
|
||||
|
||||
export function prettyRole(role: string): string {
|
||||
return role.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* The lakehouse Ava reads. Shown on screen because the point of the demo is
|
||||
* that she queries the hospital's real analytics platform — naming it is part
|
||||
* of the claim.
|
||||
*/
|
||||
export const LAKEHOUSE = {
|
||||
platform: 'Databricks',
|
||||
catalog: 'workspace',
|
||||
schema: 'rcm',
|
||||
tables: ['claims', 'payers', 'departments', 'payer_policy_updates', 'finance_monthly_summary'],
|
||||
} as const;
|
||||
152
src/api/provider.tsx
Normal file
152
src/api/provider.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ZinoClient } from './client';
|
||||
import type { User } from './types';
|
||||
|
||||
interface ZinoContextValue {
|
||||
client: ZinoClient;
|
||||
user: User | null;
|
||||
login: (email: string, password: string, orgId: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const ZinoContext = createContext<ZinoContextValue | null>(null);
|
||||
|
||||
export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
|
||||
const clientRef = useRef<ZinoClient>();
|
||||
if (!clientRef.current) clientRef.current = new ZinoClient(baseUrl);
|
||||
const client = clientRef.current;
|
||||
|
||||
// Restored synchronously from the persisted JWT so a hard navigation to a
|
||||
// protected route does not flash through /login.
|
||||
const [user, setUser] = useState<User | null>(() => client.currentUser());
|
||||
|
||||
useEffect(() => {
|
||||
client.setAuthErrorHandler(() => setUser(null));
|
||||
}, [client]);
|
||||
|
||||
// The token restores WHO you are but not WHAT YOU MAY DO — it carries no roles
|
||||
// claim (only user_id / name / email / sub / exp / iat). So when a session is
|
||||
// restored from storage rather than created by a fresh login, ask the server
|
||||
// for the real user. Without this, a page refresh left roles empty and every
|
||||
// role-gated action bar rendered blank.
|
||||
//
|
||||
// Keyed on "have a token, have no roles" rather than running unconditionally:
|
||||
// a fresh login already carries roles in its response body, and re-fetching
|
||||
// would be a wasted round trip on every mount.
|
||||
useEffect(() => {
|
||||
if (!client.getToken()) return;
|
||||
if ((user?.roles?.length ?? 0) > 0) return;
|
||||
let live = true;
|
||||
client
|
||||
.me()
|
||||
.then((u) => {
|
||||
if (live) setUser(u);
|
||||
})
|
||||
.catch(() => {
|
||||
// Must not sign anyone out — the identity from the token is still good.
|
||||
// Role-gated UI falls OPEN in this case; the server enforces permissions
|
||||
// regardless of what gets rendered.
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, user?.id, user?.roles?.length]);
|
||||
|
||||
const value = useMemo<ZinoContextValue>(
|
||||
() => ({
|
||||
client,
|
||||
user,
|
||||
login: async (email, password, orgId) => {
|
||||
const res = await client.login(email, password, orgId);
|
||||
setUser(res.user ?? client.currentUser());
|
||||
},
|
||||
logout: () => {
|
||||
client.logout();
|
||||
setUser(null);
|
||||
},
|
||||
}),
|
||||
[client, user],
|
||||
);
|
||||
|
||||
return <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>;
|
||||
}
|
||||
|
||||
export function useZino(): ZinoContextValue {
|
||||
const ctx = useContext(ZinoContext);
|
||||
if (!ctx) throw new Error('useZino must be used within <ZinoProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export interface QueryState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `fn` when a dependency changes, tracking loading/error and discarding
|
||||
* results from a superseded call.
|
||||
*
|
||||
* `refetchMs` polls. That matters here more than in most consoles: a passenger
|
||||
* case moves Pending Contact → In Outreach → Contacted → Rebooked on its own,
|
||||
* driven by a worker and an AI employee, with no user action to hang a refresh
|
||||
* off. A stale screen would look like a stuck system.
|
||||
*/
|
||||
export function useQuery<T>(
|
||||
fn: () => Promise<T>,
|
||||
deps: unknown[],
|
||||
enabled = true,
|
||||
refetchMs?: number,
|
||||
): QueryState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
const refetch = useCallback(() => setTick((t) => t + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fn()
|
||||
.then((d) => live && setData(d))
|
||||
.catch((e: unknown) => {
|
||||
if (live)
|
||||
setError(
|
||||
e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Request failed'),
|
||||
);
|
||||
})
|
||||
.finally(() => live && setLoading(false));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, tick, enabled]);
|
||||
|
||||
// Polling is kept in its own effect so a manual refetch does not restart the
|
||||
// interval, and the interval does not cancel an in-flight manual refresh.
|
||||
useEffect(() => {
|
||||
if (!enabled || !refetchMs || refetchMs <= 0) return;
|
||||
const id = setInterval(() => setTick((t) => t + 1), refetchMs);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, enabled, refetchMs]);
|
||||
|
||||
return { data, loading, error, refetch };
|
||||
}
|
||||
159
src/api/types.ts
Normal file
159
src/api/types.ts
Normal file
@ -0,0 +1,159 @@
|
||||
// Shapes returned by the Zino gateway. Only the parts this console reads are
|
||||
// modelled; unknown extras survive as index signatures rather than being
|
||||
// dropped, because record views return every configured column and the set
|
||||
// changes whenever the app is redeployed.
|
||||
|
||||
export interface ApiError {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface RecordViewField {
|
||||
type: string;
|
||||
field_key: string;
|
||||
field_uid: string;
|
||||
output_label: string;
|
||||
activity_id: string;
|
||||
data_type: string;
|
||||
is_filter: boolean;
|
||||
is_search: boolean;
|
||||
}
|
||||
|
||||
export interface RecordViewResponse {
|
||||
config: { fields: RecordViewField[] };
|
||||
data?: Row[];
|
||||
records?: Row[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** A record-view row. Values are whatever the column's data_type implies. */
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export interface RecordViewParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
||||
}
|
||||
|
||||
export interface DetailViewResponse {
|
||||
config: { fields: RecordViewField[] };
|
||||
data: Row;
|
||||
}
|
||||
|
||||
/** One entry in an instance's activity log. */
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
user_roles: string[] | null;
|
||||
activity_id: string;
|
||||
activity_name?: string;
|
||||
data: Record<string, unknown>;
|
||||
execution_state: string;
|
||||
state_name?: string;
|
||||
created_at?: string;
|
||||
performed_at?: string;
|
||||
/** What was submitted, already labelled and typed by view-service. This is
|
||||
* what lets the trace show WHAT each step did, not just who did it. */
|
||||
fields?: Array<{ field_id: string; label: string; data_type: string; value: string }>;
|
||||
ai_reasoning?: string | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_model?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One step Ava took, from `northlake_app.ava_trace`.
|
||||
*
|
||||
* `type` is the platform's own vocabulary: `context` (reading the instance),
|
||||
* `llm` (a model call), `api` (an agentic tool — for Ava that is always a
|
||||
* Databricks query), `tool` (a built-in tool such as get_task_context),
|
||||
* `decision`, `escalation`. `detail` is the one line worth rendering per type —
|
||||
* the SQL for a query, the model's narration for an llm step.
|
||||
*/
|
||||
export interface AvaTraceStep {
|
||||
instance_id: string;
|
||||
decision_id: number | null;
|
||||
seq: number;
|
||||
type: string;
|
||||
name: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
duration_ms: number | null;
|
||||
tokens_in: number | null;
|
||||
tokens_out: number | null;
|
||||
cost: number | null;
|
||||
detail: string | null;
|
||||
/** Rows the query returned. Null for anything that is not a query. */
|
||||
rows_returned: number | null;
|
||||
/** Databricks' own verdict: SUCCEEDED / FAILED. A FAILED query Ava then
|
||||
* corrects is worth showing — it is what a real analyst does. */
|
||||
query_state: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** What Ava submitted, from `northlake_app.ava_decisions`. */
|
||||
export interface AvaDecision {
|
||||
id: number;
|
||||
instance_id: string;
|
||||
activity_id: string;
|
||||
status: string;
|
||||
confidence: number | null;
|
||||
cost: number | null;
|
||||
llm_calls: number | null;
|
||||
duration_ms: number | null;
|
||||
/** Which model actually produced this. Displayed deliberately: a silent
|
||||
* model substitution once cost a whole day, and nobody could see it. */
|
||||
model: string | null;
|
||||
reasoning: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** A field on an activity form, as the view-service describes it. */
|
||||
export interface FormField {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
type: string;
|
||||
data_type: string;
|
||||
mandatory?: boolean;
|
||||
properties?: { options?: Array<{ label: string; value: string }> } & Record<string, unknown>;
|
||||
mapped_workflow_field?: string;
|
||||
}
|
||||
|
||||
export interface FormScreenResponse {
|
||||
activity_uid: string;
|
||||
activity_name: string;
|
||||
workflow_uuid: string;
|
||||
fields: FormField[];
|
||||
grid_config?: Array<Record<string, unknown>>;
|
||||
field_defaults?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ActivityResult {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
instance_id?: number;
|
||||
status_code?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
331
src/components/ActivityForm.tsx
Normal file
331
src/components/ActivityForm.tsx
Normal file
@ -0,0 +1,331 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core';
|
||||
import type { FormField } from '../api/types';
|
||||
import { WORKFLOW } from '../api/config';
|
||||
|
||||
/**
|
||||
* Renders an activity's form from the LIVE schema.
|
||||
*
|
||||
* Nothing about the fields is hardcoded: labels, types, select options and
|
||||
* which are mandatory all come from `/view/form-screens`. Add a field to the
|
||||
* activity, redeploy, and it appears here — which matters because this
|
||||
* console's whole point is that the workflow is the source of truth, not the
|
||||
* frontend's idea of it.
|
||||
*
|
||||
* The submit is deliberately thin. When the workflow refuses an activity —
|
||||
* most often "activity not allowed in state …", or an RBAC refusal — the
|
||||
* operator sees that sentence verbatim. That is the state machine explaining
|
||||
* itself, and burying it under "Something went wrong" would throw away the
|
||||
* only clue.
|
||||
*/
|
||||
export function ActivityForm({
|
||||
open,
|
||||
onClose,
|
||||
activityUid,
|
||||
instanceId,
|
||||
title,
|
||||
subtitle,
|
||||
submitLabel = 'Submit',
|
||||
danger = false,
|
||||
seed,
|
||||
prefill,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
activityUid: string;
|
||||
/** Omit to START a new instance (the INIT activity). */
|
||||
instanceId?: number | string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
submitLabel?: string;
|
||||
danger?: boolean;
|
||||
/** Values to pre-fill and LOCK — fields that identify the thing being acted
|
||||
* on, where letting someone edit it would only be a way to get it wrong. */
|
||||
seed?: Record<string, unknown>;
|
||||
/** Values to pre-fill and leave EDITABLE.
|
||||
*
|
||||
* Different intent from `seed`: an approval note is the manager's note, so
|
||||
* the form must not lock it — but making him type a paragraph from scratch,
|
||||
* on a screen that already states every fact he would type, turns a decision
|
||||
* into a data-entry chore. The form opens with a draft grounded in Ava's
|
||||
* findings, and he edits or replaces it. */
|
||||
prefill?: Record<string, unknown>;
|
||||
onDone?: (result: { instance_id?: number }) => void;
|
||||
}) {
|
||||
const { client } = useZino();
|
||||
const schema = useQuery(
|
||||
() => client.formSchema(activityUid, instanceId),
|
||||
[activityUid, instanceId],
|
||||
open,
|
||||
);
|
||||
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const fields = schema.data?.fields ?? [];
|
||||
|
||||
/**
|
||||
* Re-key a values bag onto the form's OWN field ids.
|
||||
*
|
||||
* A form field's id is not the workflow field's name. On this app the
|
||||
* activity field keys are suffixed by hand at seed time
|
||||
* (`summary__findings`, `approval_note__approve`) because
|
||||
* tbl_wf_version_field_keys enforces ONE key namespace per version across
|
||||
* globals and every activity's fields — and deploy also suffixes collisions
|
||||
* with `_<n>`. Everything computed from a record row is keyed the way the
|
||||
* DATA is keyed (the global name), so it must be translated on the way in.
|
||||
*
|
||||
* Untranslated it fails twice over, and the first failure hides the second:
|
||||
* the drafted value lands under a key no control reads, so the form opens
|
||||
* blank; and it then rides along in the submit payload, where the workflow
|
||||
* rejects it as an unknown field — `validation failed for activity
|
||||
* hdfc-act-cm-approve: cm_approved_amount(unknown), …`.
|
||||
*
|
||||
* Match on the exact id first, then on the id with a trailing `_<n>` or a
|
||||
* `__<activity>` suffix removed. Never hardcode a suffix: `_<n>` is
|
||||
* deploy-assigned, and `__findings` / `__approve` are this app's own.
|
||||
*/
|
||||
const toFieldKeys = (bag: Record<string, unknown> | undefined) => {
|
||||
if (!bag) return {};
|
||||
const byId = new Map(fields.map((f) => [f.id, f]));
|
||||
const bySlug = new Map<string, string>();
|
||||
for (const f of fields) {
|
||||
const slug = f.id.replace(/_\d+$/, '').replace(/__[a-z-]+$/, '');
|
||||
if (slug !== f.id && !byId.has(slug) && !bySlug.has(slug)) bySlug.set(slug, f.id);
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(bag)) {
|
||||
const id = byId.has(k) ? k : bySlug.get(k);
|
||||
if (id) out[id] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Precedence, weakest first: the platform's own field defaults, then our
|
||||
// computed draft, then the seeded identifiers (which also become read-only).
|
||||
const initial = useMemo(
|
||||
() => ({
|
||||
...(schema.data?.field_defaults ?? {}),
|
||||
...toFieldKeys(prefill),
|
||||
...toFieldKeys(seed),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[schema.data, JSON.stringify(prefill), JSON.stringify(seed)],
|
||||
);
|
||||
|
||||
const current = { ...initial, ...values };
|
||||
|
||||
const missing = fields
|
||||
.filter((f) => f.mandatory)
|
||||
.filter((f) => {
|
||||
const v = current[f.id];
|
||||
return v === undefined || v === null || v === '';
|
||||
})
|
||||
.map((f) => f.id);
|
||||
|
||||
function reset() {
|
||||
setValues({});
|
||||
setTouched(false);
|
||||
setErr(null);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setTouched(true);
|
||||
if (missing.length) return;
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
// Submit ONLY what this form actually has a field for. The workflow
|
||||
// validates every key in the payload and refuses the whole submission
|
||||
// on an unknown one, so a stray key is not ignored — it is fatal. This
|
||||
// is the backstop for the re-keying above: if a translation ever misses,
|
||||
// the field simply arrives empty instead of failing the activity.
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (Object.prototype.hasOwnProperty.call(current, f.id)) payload[f.id] = current[f.id];
|
||||
}
|
||||
|
||||
const res = instanceId
|
||||
? await client.performActivity(WORKFLOW, instanceId, activityUid, payload)
|
||||
: await client.startInstance(WORKFLOW, activityUid, payload);
|
||||
reset();
|
||||
onDone?.({ instance_id: res.instance_id });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr((e as { message?: string })?.message ?? 'Could not submit');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
wide={fields.length > 8}
|
||||
>
|
||||
{schema.loading && <Spinner label="Loading form…" />}
|
||||
{schema.error && <ErrorNote>{schema.error}</ErrorNote>}
|
||||
|
||||
{!schema.loading && !schema.error && (
|
||||
<div className="space-y-4">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
This activity takes no input — submitting records it as performed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={fields.length > 8 ? 'grid grid-cols-1 gap-3 sm:grid-cols-2' : 'space-y-3'}>
|
||||
{fields.map((f) => (
|
||||
<FieldControl
|
||||
key={f.id}
|
||||
field={f}
|
||||
value={current[f.id]}
|
||||
readOnly={Object.prototype.hasOwnProperty.call(toFieldKeys(seed), f.id)}
|
||||
error={touched && missing.includes(f.id) ? 'Required' : null}
|
||||
onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-line pt-4">
|
||||
<Button kind="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button kind={danger ? 'danger' : 'primary'} onClick={submit} busy={busy}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
error,
|
||||
}: {
|
||||
field: FormField;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
readOnly?: boolean;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const opts = field.properties?.options ?? [];
|
||||
|
||||
// Long prose gets a textarea whatever the schema says. Every long field on
|
||||
// this workflow is a note back to Ava, and a single-line input for a
|
||||
// paragraph is unusable.
|
||||
const isProse = field.data_type === 'longtext';
|
||||
|
||||
const control = (() => {
|
||||
switch (field.data_type) {
|
||||
case 'select':
|
||||
return (
|
||||
<Select value={String(value ?? '')} options={opts} onChange={onChange} />
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<label className="flex items-center gap-2.5 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={readOnly}
|
||||
checked={value === true || value === 'true'}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="size-4 rounded border-line bg-panel2 accent-brand"
|
||||
/>
|
||||
<span className="text-sm text-ink">Yes</span>
|
||||
</label>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
disabled={readOnly}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
onChange={(v) => onChange(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
disabled={readOnly}
|
||||
value={String(value ?? '').slice(0, 10)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
// A `datetime-local` input yields "2026-08-17T18:31" — no seconds, no
|
||||
// timezone. The server validates this field as a timestamp and rejects
|
||||
// that shape outright:
|
||||
// validation failed for activity hdfc-act-disburse: disbursed_at(type)
|
||||
// So the browser value is converted to RFC3339 UTC on the way out, and
|
||||
// back to local wall-clock on the way in. Storing the ISO string and
|
||||
// slicing it for display would show the UTC time in the picker — 13:01
|
||||
// for an 18:31 selection — which looks like the control losing input.
|
||||
case 'datetime':
|
||||
return (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
disabled={readOnly}
|
||||
value={isoToLocalInput(value)}
|
||||
onChange={(v) => onChange(localInputToIso(v))}
|
||||
/>
|
||||
);
|
||||
case 'longtext':
|
||||
return <Textarea value={String(value ?? '')} rows={6} onChange={onChange} />;
|
||||
// Anything unrecognised falls back to text rather than disappearing: a
|
||||
// field the operator cannot see is a field the submission silently omits.
|
||||
default:
|
||||
return (
|
||||
<Input disabled={readOnly} value={String(value ?? '')} onChange={onChange} />
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={isProse ? 'sm:col-span-full' : undefined}>
|
||||
<Label required={field.mandatory}>{field.name}</Label>
|
||||
{control}
|
||||
{error && <p className="mt-1 text-xs font-medium text-bad">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isoToLocalInput(v: unknown): string {
|
||||
const s = String(v ?? '');
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
// Not a parseable instant — hand it back untouched rather than blanking the
|
||||
// field, so a value the server sent stays visible even if we misread it.
|
||||
if (Number.isNaN(d.getTime())) return s.slice(0, 16);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "2026-09-15T18:31" → RFC3339 UTC.
|
||||
*
|
||||
* A `datetime-local` input yields no seconds and no timezone, and the server
|
||||
* validates a datetime field as a timestamp — so the browser's shape is
|
||||
* rejected outright as `…(type)`. Converting here, and back in
|
||||
* isoToLocalInput, keeps the picker showing local wall-clock while the wire
|
||||
* carries an instant.
|
||||
*/
|
||||
function localInputToIso(v: string): string {
|
||||
if (!v) return '';
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? v : d.toISOString();
|
||||
}
|
||||
217
src/components/AvaLive.tsx
Normal file
217
src/components/AvaLive.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gavel,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from './core';
|
||||
import type { AvaDecision, AvaTraceStep } from '../api/types';
|
||||
import { LAKEHOUSE } from '../api/config';
|
||||
import { money } from '../format';
|
||||
|
||||
/**
|
||||
* THE DEMO. Every step Ava took, in order, as she takes it.
|
||||
*
|
||||
* WHY THIS COMPONENT EXISTS
|
||||
*
|
||||
* Anyone can be told "an AI analysed your denials". The only way to make that
|
||||
* claim checkable is to show the work: the actual SQL she sent to Databricks,
|
||||
* in the order she sent it, with what came back and how long each took. A
|
||||
* viewer watching a query appear, then the next one narrow on its result, is
|
||||
* watching an investigation — not a chatbot answering.
|
||||
*
|
||||
* Nothing here is asserted by the frontend. Every row is a
|
||||
* `aiemployee.tbl_ai_trace_events` record the platform wrote while she worked,
|
||||
* read through the rdbms bridge (see client.avaTrace). The SQL text is the
|
||||
* statement the agentic tool actually sent.
|
||||
*
|
||||
* WHAT IS DELIBERATELY NOT COLLAPSED: a FAILED query. When Ava writes bad SQL
|
||||
* and corrects it on the next turn, that is the most human thing on the screen
|
||||
* and the most convincing. Hiding it would be flattering and dishonest.
|
||||
*/
|
||||
|
||||
const TYPE_META: Record<
|
||||
string,
|
||||
{ label: string; icon: typeof Database; tone: 'violet' | 'sky' | 'slate' | 'emerald' }
|
||||
> = {
|
||||
context: { label: 'Read the case', icon: FileSearch, tone: 'slate' },
|
||||
tool: { label: 'Read the case', icon: FileSearch, tone: 'slate' },
|
||||
knowledge: { label: 'Checked her notes', icon: FileSearch, tone: 'slate' },
|
||||
llm: { label: 'Thought', icon: Brain, tone: 'violet' },
|
||||
plan: { label: 'Planned', icon: Brain, tone: 'violet' },
|
||||
api: { label: `Queried ${LAKEHOUSE.platform}`, icon: Database, tone: 'sky' },
|
||||
decision: { label: 'Decided', icon: Gavel, tone: 'emerald' },
|
||||
escalation: { label: 'Escalated', icon: AlertTriangle, tone: 'slate' },
|
||||
};
|
||||
|
||||
function meta(type: string) {
|
||||
return TYPE_META[type] ?? { label: type, icon: FileSearch, tone: 'slate' as const };
|
||||
}
|
||||
|
||||
function secs(ms: number | null): string {
|
||||
if (!ms && ms !== 0) return '';
|
||||
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function clock(v: string | null): string {
|
||||
if (!v) return '';
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime())
|
||||
? ''
|
||||
: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
export function AvaLive({
|
||||
steps,
|
||||
decisions,
|
||||
working,
|
||||
}: {
|
||||
steps: AvaTraceStep[];
|
||||
decisions: AvaDecision[];
|
||||
/** True while the case sits in a state Ava owns — drives the live dot and
|
||||
* the "still working" footer, so an empty trace reads as "starting" rather
|
||||
* than "nothing happened". */
|
||||
working: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
|
||||
const queries = steps.filter((s) => s.type === 'api');
|
||||
const failed = queries.filter((s) => s.query_state === 'FAILED').length;
|
||||
const spend = decisions.reduce((t, d) => t + (Number(d.cost) || 0), 0);
|
||||
const elapsed =
|
||||
steps.length > 1
|
||||
? (new Date(steps[steps.length - 1].ended_at ?? steps[steps.length - 1].started_at).getTime() -
|
||||
new Date(steps[0].started_at).getTime()) /
|
||||
1000
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<section className="lift overflow-hidden rounded-lg border border-ai-edge bg-panel">
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-ai-edge bg-ai-soft px-4 py-3">
|
||||
<Brain className="h-4 w-4 text-ai" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-ai">Ava's working</h2>
|
||||
<p className="mt-0.5 text-xs text-ai/75">
|
||||
Every step, from the platform's own trace — not a summary
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
{working && (
|
||||
<Badge tone="violet" dot>
|
||||
working
|
||||
</Badge>
|
||||
)}
|
||||
<Badge tone="sky">{queries.length} queries</Badge>
|
||||
{failed > 0 && <Badge tone="amber">{failed} corrected</Badge>}
|
||||
{elapsed > 0 && <Badge tone="slate">{Math.round(elapsed)}s</Badge>}
|
||||
{spend > 0 && <Badge tone="slate">{money(spend.toFixed(2))}</Badge>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<p className="flex items-center gap-2 px-4 py-8 text-sm text-faint">
|
||||
{working && <Loader2 className="h-4 w-4 animate-spin text-ai" />}
|
||||
{working
|
||||
? 'Ava has picked up the case. Her first query appears here within a few seconds.'
|
||||
: 'No steps recorded for this case.'}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="max-h-[32rem] overflow-y-auto px-4">
|
||||
{steps.map((s, i) => {
|
||||
const m = meta(s.type);
|
||||
const Icon = m.icon;
|
||||
const isQuery = s.type === 'api';
|
||||
const isOpen = open === i;
|
||||
const bad = s.status !== 'ok' || s.query_state === 'FAILED';
|
||||
|
||||
return (
|
||||
<li key={`${s.seq}-${i}`} className="border-b border-line last:border-0">
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
className="flex w-full items-start gap-3 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand"
|
||||
>
|
||||
<span className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
|
||||
<span
|
||||
className={`rounded-full p-1.5 ring-1 ring-inset ${
|
||||
bad
|
||||
? 'bg-warn-soft text-warn ring-warn-edge'
|
||||
: isQuery
|
||||
? 'bg-info-soft text-info ring-info-edge'
|
||||
: 'bg-ai-soft text-ai ring-ai-edge'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="tnum text-[10px] text-faint">{i + 1}</span>
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-ink">{m.label}</span>
|
||||
{isQuery && s.rows_returned != null && s.query_state !== 'FAILED' && (
|
||||
<span className="tnum text-[11px] text-faint">
|
||||
{s.rows_returned} row{s.rows_returned === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
{bad && <Badge tone="amber">{s.query_state === 'FAILED' ? 'SQL error' : s.status}</Badge>}
|
||||
<span className="tnum ml-auto text-[11px] text-faint">
|
||||
{clock(s.started_at)} · {secs(s.duration_ms)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 shrink-0 text-faint transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{/* One line of the step, so the list reads without opening
|
||||
anything. A query shows its SQL; a thought shows its
|
||||
first sentence. */}
|
||||
{s.detail && (
|
||||
<span
|
||||
className={`mt-1 block ${isQuery ? 'sql text-muted' : 'text-xs text-muted'} ${
|
||||
isOpen ? '' : 'line-clamp-2'
|
||||
}`}
|
||||
>
|
||||
{isOpen ? s.detail : s.detail.replace(/\s+/g, ' ').slice(0, 220)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (s.error || (s.tokens_in ?? 0) > 0) && (
|
||||
<div className="pb-3 pl-11 text-[11px] text-faint">
|
||||
{s.error && <p className="mb-1 text-bad">{s.error}</p>}
|
||||
{(s.tokens_in ?? 0) > 0 && (
|
||||
<p className="tnum">
|
||||
{s.tokens_in} in / {s.tokens_out} out
|
||||
{s.cost ? ` · ${money(Number(s.cost).toFixed(3))}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-2 border-t border-line bg-panel2 px-4 py-2.5 text-[11px] text-faint">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Read-only against{' '}
|
||||
<span className="font-semibold text-muted">
|
||||
{LAKEHOUSE.platform} · {LAKEHOUSE.catalog}.{LAKEHOUSE.schema}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Every statement wrapped in a SELECT — she cannot write
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
221
src/components/Findings.tsx
Normal file
221
src/components/Findings.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, Target } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge, Card, Facts } from './core';
|
||||
import type { Row } from '../api/types';
|
||||
import { money, num, pct, recommendations, splitRecommendation, sqlSteps, str } from '../format';
|
||||
|
||||
/**
|
||||
* What Ava concluded, and what she wants done about it.
|
||||
*
|
||||
* EVERY VALUE HERE IS HERS. Nothing on this panel is computed by the frontend
|
||||
* or inferred from anything — each field is read off the instance exactly as
|
||||
* she submitted it. The violet tint is the console's one semantic rule: if it
|
||||
* is violet, an agent produced it.
|
||||
*
|
||||
* The recommendations are deliberately cards rather than a paragraph. A
|
||||
* manager approves a LIST of actions with owners and amounts; rendering it as
|
||||
* prose invites him to skim it as commentary.
|
||||
*/
|
||||
export function Findings({ row }: { row: Row }) {
|
||||
const summary = str(row.summary);
|
||||
const cause = str(row.root_cause);
|
||||
const secondary = str(row.secondary_findings);
|
||||
const recs = recommendations(row.recommendations);
|
||||
const confidence = num(row.confidence);
|
||||
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
|
||||
const atRisk = num(row.amount_at_risk);
|
||||
const recoverable = num(row.recoverable_amount);
|
||||
const affected = num(row.affected_claims);
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* --------------- the numbers, before the prose --------------- */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="Claims affected" value={affected == null ? '—' : String(affected)} />
|
||||
<Stat label="Billed at risk" value={money(atRisk)} tone="bad" />
|
||||
<Stat
|
||||
label="Recoverable"
|
||||
value={money(recoverable)}
|
||||
tone="good"
|
||||
note={
|
||||
atRisk && recoverable
|
||||
? `${Math.round((recoverable / atRisk) * 100)}% of what is at risk`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* --------------- what she found --------------- */}
|
||||
<section className="lift rounded-lg border border-ai-edge bg-panel">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b border-ai-edge bg-ai-soft px-4 py-3">
|
||||
<Target className="h-4 w-4 text-ai" />
|
||||
<h2 className="text-sm font-semibold text-ai">What Ava found</h2>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Her own first check, surfaced as a badge: she reconciles the
|
||||
claims data against the finance report before she trusts it,
|
||||
and a reviewer should be able to see that she did. */}
|
||||
<Badge tone={reconciled ? 'emerald' : 'amber'}>
|
||||
{reconciled ? 'data reconciled' : 'data NOT reconciled'}
|
||||
</Badge>
|
||||
{confidence != null && (
|
||||
<Badge tone={confidence >= 0.9 ? 'emerald' : confidence >= 0.7 ? 'amber' : 'rose'}>
|
||||
{pct(confidence * 100, 0)} confident
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-ink">{summary}</p>
|
||||
|
||||
{cause && (
|
||||
<div className="rounded-md border border-line bg-panel2 px-3.5 py-3">
|
||||
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">
|
||||
Root cause
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed whitespace-pre-wrap text-ink">{cause}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secondary && !/^none found/i.test(secondary.trim()) && (
|
||||
<div className="rounded-md border border-warn-edge bg-warn-soft px-3.5 py-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-warn uppercase">
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> A second, separate pattern
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed whitespace-pre-wrap text-ink">{secondary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --------------- what she wants done --------------- */}
|
||||
{recs.length > 0 && (
|
||||
<Card title="What she recommends" subtitle="Ranked by money. You approve or send it back.">
|
||||
<ol className="divide-y divide-line">
|
||||
{recs.map((block, i) => {
|
||||
const { title, body } = splitRecommendation(block);
|
||||
return (
|
||||
<li key={i} className="px-4 py-3.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="tnum mt-0.5 shrink-0 rounded-full bg-panel2 px-2 py-0.5 text-[11px] font-semibold text-muted ring-1 ring-line ring-inset">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{title && <p className="text-[13px] font-semibold text-ink">{title}</p>}
|
||||
<p className="mt-0.5 text-sm leading-relaxed whitespace-pre-wrap text-muted">
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Her evidence: every statement she ran, in order, with her own note on what
|
||||
* came back.
|
||||
*
|
||||
* Collapsed by default. It is the proof, not the story — a reviewer opens it to
|
||||
* check a number, and a sceptic in the room opens it to see that the SQL is
|
||||
* real. Both are better served by a closed section than by a wall of query text
|
||||
* above the recommendations.
|
||||
*/
|
||||
export function SqlEvidence({ row }: { row: Row }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const steps = sqlSteps(row.sql_log);
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="lift overflow-hidden rounded-lg border border-line bg-panel">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-good" />
|
||||
<span className="text-sm font-semibold text-ink">Her evidence</span>
|
||||
<span className="text-xs text-faint">
|
||||
{steps.length} statement{steps.length === 1 ? '' : 's'}, exactly as she ran them
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`ml-auto h-4 w-4 text-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ol className="divide-y divide-line border-t border-line">
|
||||
{steps.map((s, i) => (
|
||||
<li key={i} className="px-4 py-3">
|
||||
{s.label && (
|
||||
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">
|
||||
{s.label}
|
||||
</p>
|
||||
)}
|
||||
<pre className="sql mt-1 text-muted">{s.sql}</pre>
|
||||
{s.note && <p className="mt-1.5 text-xs text-ink">{s.note}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The re-check, when there is one. Same shape both ways: a rate against its
|
||||
* baseline, so "recovered" and "still elevated" are read the same way. */
|
||||
export function Recheck({ row }: { row: Row }) {
|
||||
const summary = str(row.recheck_summary);
|
||||
if (!summary) return null;
|
||||
const current = num(row.current_rate);
|
||||
const baseline = num(row.baseline_rate);
|
||||
const recovered = num(row.recovered_amount);
|
||||
const better = current != null && baseline != null && current - baseline <= 2;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="The re-check"
|
||||
subtitle="Ava came back on her own after the actions were approved"
|
||||
actions={<Badge tone={better ? 'emerald' : 'amber'}>{better ? 'back to baseline' : 'still elevated'}</Badge>}
|
||||
>
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<Facts
|
||||
cols={3}
|
||||
rows={[
|
||||
{ label: 'Rate now', value: pct(current), tone: better ? 'emerald' : 'rose' },
|
||||
{ label: 'Baseline', value: pct(baseline) },
|
||||
{ label: 'Recovered', value: recovered == null ? '—' : money(recovered), tone: 'emerald' },
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted">{summary}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
note,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'good' | 'bad';
|
||||
note?: string;
|
||||
}) {
|
||||
const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink';
|
||||
return (
|
||||
<div className="lift rounded-lg border border-line bg-panel px-4 py-3">
|
||||
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</p>
|
||||
<p className={`tnum mt-1 text-xl font-bold ${text}`}>{value}</p>
|
||||
{note && <p className="mt-0.5 text-[11px] text-faint">{note}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/NorthlakeLogo.tsx
Normal file
40
src/components/NorthlakeLogo.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Northlake Health's mark.
|
||||
*
|
||||
* A teal rounded square carrying a pulse line with a chevron rise — a hospital
|
||||
* signature without a cross or a caduceus, both of which read as generic
|
||||
* clip-art at this size. Kept as inline SVG rather than an asset file because
|
||||
* this build is promoted between environments unchanged and placed at an
|
||||
* unknown mount path; an <img src> would have to resolve against it. The same
|
||||
* coordinates are the favicon in index.html — change one, change the other.
|
||||
*/
|
||||
export function NorthlakeMark({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 40 40" aria-hidden="true">
|
||||
<rect width="40" height="40" rx="9" className="fill-brand" />
|
||||
<path
|
||||
d="M8 25h6l3-7 4 12 3-8h8"
|
||||
fill="none"
|
||||
stroke="var(--color-brand-fg)"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The lockup: mark, hospital, and what this particular console is. */
|
||||
export function NorthlakeLockup({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2.5">
|
||||
<NorthlakeMark size={size} />
|
||||
<span className="leading-tight">
|
||||
<span className="block text-[15px] font-bold tracking-tight text-ink">Northlake Health</span>
|
||||
<span className="block text-[11px] font-semibold tracking-wide text-faint uppercase">
|
||||
Denials Desk
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
22
src/components/ThemeToggle.tsx
Normal file
22
src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
/**
|
||||
* The `onNavy` variant went with the navy header. The chrome is `bg-panel` in
|
||||
* both themes now — white in light, navy in dark — so one set of token classes
|
||||
* covers every place this button appears.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme();
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={`Switch to ${next} mode`}
|
||||
aria-label={`Switch to ${next} mode`}
|
||||
className="rounded-md p-2 text-muted transition-colors hover:bg-panel2 hover:text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
336
src/components/core/index.tsx
Normal file
336
src/components/core/index.tsx
Normal file
@ -0,0 +1,336 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import type { Tone } from '../../api/config';
|
||||
|
||||
/**
|
||||
* The whole UI kit, in one file.
|
||||
*
|
||||
* Every colour comes from a token defined in styles.css — never a literal and
|
||||
* never a raw Tailwind shade. A hex or a `text-emerald-700` in here is
|
||||
* invisible to the theme toggle and would be wrong in one of the two themes.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tone → token classes. One place, so a badge and a stat value that mean the
|
||||
// same thing are literally the same colour.
|
||||
// ---------------------------------------------------------------------------
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
slate: 'text-idle',
|
||||
amber: 'text-warn',
|
||||
sky: 'text-info',
|
||||
emerald: 'text-good',
|
||||
rose: 'text-bad',
|
||||
violet: 'text-ai',
|
||||
};
|
||||
|
||||
const TONE_CHIP: Record<Tone, string> = {
|
||||
slate: 'bg-idle-soft text-idle ring-idle-edge',
|
||||
amber: 'bg-warn-soft text-warn ring-warn-edge',
|
||||
sky: 'bg-info-soft text-info ring-info-edge',
|
||||
emerald: 'bg-good-soft text-good ring-good-edge',
|
||||
rose: 'bg-bad-soft text-bad ring-bad-edge',
|
||||
violet: 'bg-ai-soft text-ai ring-ai-edge',
|
||||
};
|
||||
|
||||
export function toneText(tone: Tone): string {
|
||||
return TONE_TEXT[tone];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'slate',
|
||||
dot = false,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: Tone;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset ${TONE_CHIP[tone]} ${className}`}
|
||||
>
|
||||
{dot && <span className="live-dot h-1.5 w-1.5 rounded-full bg-current" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Button
|
||||
// ---------------------------------------------------------------------------
|
||||
type ButtonKind = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
|
||||
const BUTTON_KIND: Record<ButtonKind, string> = {
|
||||
primary: 'bg-brand text-brand-fg hover:bg-brand-hover',
|
||||
secondary: 'bg-panel2 text-ink ring-1 ring-inset ring-line hover:bg-panel3',
|
||||
// HDFC red, and the only place besides the logo it appears. A red button
|
||||
// here always means the file stops.
|
||||
danger: 'bg-signal text-white hover:bg-signal-hover',
|
||||
ghost: 'text-muted hover:bg-panel2 hover:text-ink',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
onClick,
|
||||
kind = 'secondary',
|
||||
disabled = false,
|
||||
busy = false,
|
||||
type = 'button',
|
||||
className = '',
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
kind?: ButtonKind;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
disabled={disabled || busy}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-not-allowed disabled:opacity-50 ${BUTTON_KIND[kind]} ${className}`}
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={`lift rounded-lg border border-line bg-panel ${className}`}>
|
||||
{(title || actions) && (
|
||||
<header className="flex items-start justify-between gap-4 border-b border-line px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h2 className="text-sm font-semibold text-ink">{title}</h2>}
|
||||
{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form controls
|
||||
// ---------------------------------------------------------------------------
|
||||
const FIELD =
|
||||
'w-full rounded-md border border-line bg-panel2 px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-brand focus:bg-panel focus:outline-none focus:ring-2 focus:ring-brand/25';
|
||||
|
||||
export function Label({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||
return (
|
||||
<label className="mb-1.5 block text-xs font-semibold tracking-wide text-muted uppercase">
|
||||
{children}
|
||||
{required && <span className="ml-1 text-signal">*</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className={FIELD}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
className={FIELD}
|
||||
rows={rows}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select…',
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<select className={FIELD} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Spinner({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-faint">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{label ?? 'Loading…'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorNote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border border-bad-edge bg-bad-soft px-3 py-2 text-sm text-bad">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ children }: { children: ReactNode }) {
|
||||
return <div className="py-12 text-center text-sm text-faint">{children}</div>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
wide = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
// Escape closes, and the body does not scroll behind the dialogue.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-scrim p-4 sm:p-8">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={`lift-lg w-full rounded-lg border border-line bg-panel ${wide ? 'max-w-3xl' : 'max-w-xl'}`}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-ink">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="-m-1 rounded p-1 text-faint hover:bg-panel2 hover:text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Definition list — the workhorse for showing a file's fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Facts({
|
||||
rows,
|
||||
cols = 2,
|
||||
}: {
|
||||
rows: Array<{ label: string; value: ReactNode; tone?: Tone; wide?: boolean }>;
|
||||
cols?: 1 | 2 | 3;
|
||||
}) {
|
||||
const grid = cols === 1 ? 'sm:grid-cols-1' : cols === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2';
|
||||
return (
|
||||
<dl className={`grid grid-cols-1 gap-x-6 gap-y-3.5 ${grid}`}>
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className={r.wide ? 'sm:col-span-full' : undefined}>
|
||||
<dt className="text-[11px] font-semibold tracking-wide text-faint uppercase">{r.label}</dt>
|
||||
<dd className={`tnum mt-0.5 text-sm ${r.tone ? TONE_TEXT[r.tone] : 'text-ink'}`}>
|
||||
{r.value === '' || r.value == null ? <span className="text-faint">—</span> : r.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
123
src/format.ts
Normal file
123
src/format.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Formatting. US conventions: this is a US hospital's revenue cycle, and every
|
||||
* number on screen is either dollars, a percentage or a claim count.
|
||||
*
|
||||
* `moneyShort` exists because a queue column has no room for $1,962,624 and
|
||||
* "$1.96M" is what a person would say out loud. Full precision stays on the
|
||||
* case page, where the number is being acted on rather than scanned.
|
||||
*/
|
||||
|
||||
const USD = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
|
||||
|
||||
export function num(v: unknown): number | null {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function str(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** $1,962,624 */
|
||||
export function money(v: unknown): string {
|
||||
const n = num(v);
|
||||
return n === null ? '—' : `$${USD.format(n)}`;
|
||||
}
|
||||
|
||||
/** $1.96M / $340K — for columns and tiles. */
|
||||
export function moneyShort(v: unknown): string {
|
||||
const n = num(v);
|
||||
if (n === null) return '—';
|
||||
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
||||
if (Math.abs(n) >= 1e3) return `$${Math.round(n / 1e3)}K`;
|
||||
return `$${USD.format(n)}`;
|
||||
}
|
||||
|
||||
/** 44.3% */
|
||||
export function pct(v: unknown, digits = 1): string {
|
||||
const n = num(v);
|
||||
return n === null ? '—' : `${n.toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
/** A stored ratio (0.7445) shown as a percentage. */
|
||||
export function ratioPct(v: unknown, digits = 1): string {
|
||||
const n = num(v);
|
||||
return n === null ? '—' : `${(n * 100).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
/** 1.34 */
|
||||
export function ratio(v: unknown, digits = 2): string {
|
||||
const n = num(v);
|
||||
return n === null ? '—' : n.toFixed(digits);
|
||||
}
|
||||
|
||||
export function dateTime(v: unknown): string {
|
||||
const s = str(v);
|
||||
if (!s) return '—';
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return s;
|
||||
return d.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function dateOnly(v: unknown): string {
|
||||
const s = str(v);
|
||||
if (!s) return '—';
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return s;
|
||||
return d.toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** A CARC code with its plain meaning, for a chip that explains itself. */
|
||||
export const CARC: Record<string, string> = {
|
||||
'CO-197': 'Prior authorization absent',
|
||||
'CO-27': 'Coverage terminated / not eligible',
|
||||
'CO-11': 'Diagnosis inconsistent with procedure',
|
||||
'CO-16': 'Missing information / billing error',
|
||||
'CO-50': 'Not medically necessary (payer)',
|
||||
'CO-29': 'Timely filing expired',
|
||||
'CO-18': 'Duplicate claim',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ava writes her recommendations as a numbered list in one text field, because
|
||||
* that is the shape a model produces reliably and a schema of five optional
|
||||
* sub-objects is not. Split on the leading "N." so each lands in its own card;
|
||||
* a block that does not parse is returned whole rather than dropped.
|
||||
*/
|
||||
export function recommendations(v: unknown): string[] {
|
||||
const s = str(v).trim();
|
||||
if (!s) return [];
|
||||
const parts = s.split(/\n(?=\s*\d+\.\s)/).map((x) => x.trim()).filter(Boolean);
|
||||
return parts.length ? parts : [s];
|
||||
}
|
||||
|
||||
/** "1. APPEALS — FILE RETRO-AUTH ($1,091,000 …)" → title + body. */
|
||||
export function splitRecommendation(block: string): { title: string; body: string } {
|
||||
const m = block.match(/^\s*(\d+)\.\s*([^.\n]+?)(?:\.|\n|$)([\s\S]*)$/);
|
||||
if (!m) return { title: '', body: block };
|
||||
return { title: m[2].trim(), body: (m[3] ?? '').trim() };
|
||||
}
|
||||
|
||||
/** Ava's SQL log is one statement per block, each prefixed with its step. */
|
||||
export function sqlSteps(v: unknown): Array<{ label: string; sql: string; note: string }> {
|
||||
return str(v)
|
||||
.split(/\n\s*\n/)
|
||||
.map((b) => b.trim())
|
||||
.filter(Boolean)
|
||||
.map((b) => {
|
||||
const lines = b.split('\n');
|
||||
const note = lines.filter((l) => l.trim().startsWith('--')).join('\n').replace(/^--\s?/gm, '').trim();
|
||||
const sql = lines.filter((l) => !l.trim().startsWith('--')).join('\n').trim();
|
||||
const m = sql.match(/^(STEP[^:]*):\s*/i);
|
||||
return { label: m ? m[1] : '', sql: m ? sql.slice(m[0].length) : sql, note };
|
||||
})
|
||||
.filter((s) => s.sql);
|
||||
}
|
||||
100
src/layout/Shell.tsx
Normal file
100
src/layout/Shell.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { LogOut, Search } from 'lucide-react';
|
||||
import { useZino } from '../api/provider';
|
||||
import { QUEUES, prettyRole } from '../api/config';
|
||||
import { NorthlakeLockup } from '../components/NorthlakeLogo';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
import { Button } from '../components/core';
|
||||
|
||||
/**
|
||||
* The chrome.
|
||||
*
|
||||
* The sidebar IS the pipeline: every entry is the record view filtered on
|
||||
* current_state_name, in the order a case actually moves. A person learns the
|
||||
* process by using the app. Awaiting Review is first because it is the only
|
||||
* queue that needs a human, and the one this console exists to serve.
|
||||
*
|
||||
* Nothing here is role-gated. Hiding a queue protects nothing — the workflow
|
||||
* re-checks RBAC on every submission — and hiding one from the person who needs
|
||||
* it is a worse failure than showing a button that comes back "not permitted".
|
||||
*/
|
||||
export function Shell() {
|
||||
const { user, logout } = useZino();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-30 bg-panel">
|
||||
<div className="flex items-center gap-4 px-4 py-2.5">
|
||||
<NorthlakeLockup />
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Button kind="primary" onClick={() => navigate('/new')}>
|
||||
<Search className="h-4 w-4" />
|
||||
Ask Ava
|
||||
</Button>
|
||||
|
||||
{user && (
|
||||
<div className="hidden text-right sm:block">
|
||||
<div className="text-[13px] leading-tight font-semibold text-ink">
|
||||
{user.name || user.email}
|
||||
</div>
|
||||
<div className="text-[11px] leading-tight text-faint">
|
||||
{(user.roles ?? []).map(prettyRole).join(' · ') || 'No role'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
className="rounded-md p-2 text-muted transition-colors hover:bg-panel2 hover:text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-[3px] bg-brand" />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 flex-col lg:flex-row">
|
||||
<nav className="shrink-0 border-b border-line bg-panel lg:w-60 lg:border-r lg:border-b-0">
|
||||
<ul className="flex gap-1 overflow-x-auto p-2 lg:flex-col lg:overflow-visible">
|
||||
{QUEUES.map((q) => (
|
||||
<li key={q.path} className="shrink-0 lg:shrink">
|
||||
<NavLink
|
||||
to={`/q/${q.path}`}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-md px-3 py-2 transition-colors ${
|
||||
isActive ? 'bg-brand text-brand-fg' : 'text-muted hover:bg-panel2 hover:text-ink'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="block text-[13px] font-semibold whitespace-nowrap">
|
||||
{q.label}
|
||||
</span>
|
||||
<span
|
||||
className={`hidden text-[11px] lg:block ${isActive ? 'text-brand-fg/70' : 'text-faint'}`}
|
||||
>
|
||||
{q.hint}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1 p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/main.tsx
Normal file
36
src/main.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import { ZinoProvider } from './api/provider';
|
||||
import { basePath, requireConfigValue } from './runtimeConfig';
|
||||
// Self-hosted rather than a CDN, for the same reason the API URL is read at
|
||||
// runtime: this build is promoted between environments and dropped behind
|
||||
// hospital proxies, and a font fetched from fonts.googleapis.com is one that
|
||||
// sometimes does not arrive.
|
||||
import '@fontsource-variable/inter';
|
||||
import './styles.css';
|
||||
|
||||
/**
|
||||
* The API base URL is read at RUNTIME, from the config.js the server writes
|
||||
* when it places the build — never compiled in. One artifact is promoted
|
||||
* between environments unchanged, so a build-time URL would point every
|
||||
* environment at whichever backend happened to build it.
|
||||
*
|
||||
* requireConfigValue throws rather than falling back, so a production build
|
||||
* with no config.js fails loudly on first paint instead of quietly calling the
|
||||
* wrong environment.
|
||||
*/
|
||||
const baseUrl = requireConfigValue('VITE_ZINO_API_URL');
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
{/* basename comes from the <base href> the server wrote, so one build
|
||||
serves any mount path without a rebuild. */}
|
||||
<BrowserRouter basename={basePath()}>
|
||||
<ZinoProvider baseUrl={baseUrl}>
|
||||
<App />
|
||||
</ZinoProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
81
src/pages/Login.tsx
Normal file
81
src/pages/Login.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { useZino } from '../api/provider';
|
||||
import { ORG_ID } from '../api/config';
|
||||
import { Button, ErrorNote, Input, Label } from '../components/core';
|
||||
import { NorthlakeLockup, NorthlakeMark } from '../components/NorthlakeLogo';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
|
||||
/**
|
||||
* Sign in.
|
||||
*
|
||||
* The org is fixed — this build serves one tenant — so it is not a field anyone
|
||||
* has to know. It still goes over the wire as a STRING: the gateway rejects a
|
||||
* number with "cannot unmarshal number into Go struct field
|
||||
* LoginRequest.org_id", which is a confusing failure to hit from a login form.
|
||||
*/
|
||||
export function Login() {
|
||||
const { login } = useZino();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!email || !password) {
|
||||
setErr('Enter your email and password');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await login(email, password, ORG_ID);
|
||||
} catch (e) {
|
||||
setErr((e as { message?: string })?.message ?? 'Could not sign in');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-bg">
|
||||
<header className="bg-panel">
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
<NorthlakeLockup />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-[3px] bg-brand" />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="lift w-full max-w-sm rounded-lg border border-line bg-panel p-6"
|
||||
>
|
||||
<NorthlakeMark size={34} />
|
||||
<h1 className="mt-5 text-lg font-semibold text-ink">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Denials Desk — revenue cycle, Northlake Health.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
<div>
|
||||
<Label required>Email</Label>
|
||||
<Input value={email} onChange={setEmail} type="email" placeholder="name@northlake.example" />
|
||||
</div>
|
||||
<div>
|
||||
<Label required>Password</Label>
|
||||
<Input value={password} onChange={setPassword} type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
<Button kind="primary" type="submit" busy={busy} className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/runtimeConfig.ts
Normal file
47
src/runtimeConfig.ts
Normal file
@ -0,0 +1,47 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__RUNTIME_CONFIG__?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration, written by the server as `config.js` when it places a
|
||||
* build — never baked in.
|
||||
*
|
||||
* One build is promoted between environments unchanged, so an API URL compiled
|
||||
* at build time would be the *source* environment's URL everywhere it lands.
|
||||
*
|
||||
* SAME_ORIGIN exists because an empty string is falsy and could never survive
|
||||
* the `||` chain, which is exactly what "use the current origin" has to do.
|
||||
*/
|
||||
export function resolveConfigValue(key: string, fallback = ''): string {
|
||||
const runtime = (window.__RUNTIME_CONFIG__ || {})[key];
|
||||
// Gated on DEV: a production build with no config.js must fail loudly rather
|
||||
// than quietly calling whichever backend happened to build it.
|
||||
const devFallback = import.meta.env.DEV ? ((import.meta.env as Record<string, string>)[key] ?? '') : '';
|
||||
const value = runtime || devFallback || fallback;
|
||||
return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value;
|
||||
}
|
||||
|
||||
export function requireConfigValue(key: string): string {
|
||||
const value = resolveConfigValue(key);
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'Missing runtime config "' + key + '". The server writes config.js when it ' +
|
||||
'places the build; for local dev set ' + key + ' in .env.',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mount path this app is served under, read from the `<base href>` the
|
||||
* server writes into index.html. Drives the router basename, so one build
|
||||
* serves any mount path without a rebuild.
|
||||
*
|
||||
* `import.meta.env.BASE_URL` is NOT usable here — with Vite's relative base it
|
||||
* resolves to "./".
|
||||
*/
|
||||
export function basePath(): string {
|
||||
return new URL(document.baseURI).pathname;
|
||||
}
|
||||
224
src/screens/CaseScreen.tsx
Normal file
224
src/screens/CaseScreen.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Brain, Clock, User } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import {
|
||||
ACT,
|
||||
NEEDS_HUMAN,
|
||||
RECORD_VIEW,
|
||||
STAGES,
|
||||
STAGE_TONE,
|
||||
actionsFor,
|
||||
stageStep,
|
||||
} from '../api/config';
|
||||
import { Badge, Button, Card, ErrorNote, Facts, Spinner } from '../components/core';
|
||||
import { ActivityForm } from '../components/ActivityForm';
|
||||
import { AvaLive } from '../components/AvaLive';
|
||||
import { Findings, Recheck, SqlEvidence } from '../components/Findings';
|
||||
import type { Action } from '../api/config';
|
||||
import { dateTime, str } from '../format';
|
||||
|
||||
/**
|
||||
* One investigation, end to end.
|
||||
*
|
||||
* The page is ordered the way a reviewer reads it, not the way the data is
|
||||
* shaped: what Ava concluded first, then what she wants done, then her
|
||||
* evidence, then the raw trace of how she got there. The actions sit at the top
|
||||
* because on a case in Awaiting Review, deciding is the only reason this screen
|
||||
* is open.
|
||||
*
|
||||
* POLLING is on while Ava owns the case (Investigating / Monitoring). That is
|
||||
* the whole live demo: the trace grows, query by query, with nobody touching
|
||||
* the keyboard. When the case is with a person nothing is moving, so the poll
|
||||
* stops rather than hammering the gateway for a screen that cannot change.
|
||||
*/
|
||||
export function CaseScreen() {
|
||||
const { id } = useParams();
|
||||
const { client, user } = useZino();
|
||||
const [form, setForm] = useState<Action | null>(null);
|
||||
|
||||
const cases = useQuery(
|
||||
() =>
|
||||
client.rows(RECORD_VIEW, {
|
||||
limit: 1,
|
||||
filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }],
|
||||
}),
|
||||
[id],
|
||||
true,
|
||||
6000,
|
||||
);
|
||||
const row = cases.data?.[0];
|
||||
const state = str(row?.current_state_name);
|
||||
const aiOwned = state === 'Investigating' || state === 'Monitoring';
|
||||
|
||||
const trace = useQuery(() => client.avaTrace(String(id)), [id], true, aiOwned ? 4000 : 0);
|
||||
const decisions = useQuery(() => client.avaDecisions(String(id)), [id], true, aiOwned ? 8000 : 0);
|
||||
|
||||
const actions = actionsFor(state, user?.roles);
|
||||
|
||||
function refresh() {
|
||||
cases.refetch();
|
||||
trace.refetch();
|
||||
decisions.refetch();
|
||||
}
|
||||
|
||||
if (cases.loading && !cases.data) return <Spinner label="Loading case…" />;
|
||||
if (cases.error) return <ErrorNote>{cases.error}</ErrorNote>;
|
||||
if (!row) return <ErrorNote>Case {id} not found.</ErrorNote>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to="/q/review"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Back to the queue
|
||||
</Link>
|
||||
|
||||
{/* ---------------- hero: who holds it, and what is asked ------------- */}
|
||||
<section className="lift rounded-lg border border-line bg-panel px-4 py-4">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="tnum text-lg font-semibold text-ink">Case #{str(row.instance_id)}</h1>
|
||||
<Badge tone={STAGE_TONE[state] ?? 'slate'} dot={aiOwned}>
|
||||
{state}
|
||||
</Badge>
|
||||
{aiOwned ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-ai">
|
||||
<Brain className="h-3.5 w-3.5" /> Ava is working
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-faint">
|
||||
<User className="h-3.5 w-3.5" /> waiting on a person
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-2xl text-sm text-muted">
|
||||
{str(row.question) || `Scope: ${str(row.scope) || 'all payers and departments'}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-[11px] text-faint">
|
||||
<Clock className="h-3 w-3" /> opened {dateTime(row.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Rail state={state} />
|
||||
|
||||
{/* The actions. Ava's own activities are deliberately absent — see the
|
||||
note on STAGE_ACTIONS in config.ts. */}
|
||||
{actions.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap items-start gap-3 border-t border-line pt-4">
|
||||
{actions.map((a) => (
|
||||
<div key={a.activity}>
|
||||
<Button kind={a.kind} onClick={() => setForm(a)}>
|
||||
{a.label}
|
||||
</Button>
|
||||
{a.hint && <p className="mt-1 max-w-[16rem] text-[11px] text-faint">{a.hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === NEEDS_HUMAN && (
|
||||
<p className="mt-4 rounded-md border border-bad-edge bg-bad-soft px-3.5 py-2.5 text-sm text-bad">
|
||||
<span className="font-semibold">Ava stopped and asked for you.</span>{' '}
|
||||
{str(row.escalation_reason) || 'No reason recorded.'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---------------- her conclusions ---------------- */}
|
||||
<Findings row={row} />
|
||||
<Recheck row={row} />
|
||||
<SqlEvidence row={row} />
|
||||
|
||||
{/* ---------------- her work, live ---------------- */}
|
||||
<AvaLive steps={trace.data ?? []} decisions={decisions.data ?? []} working={aiOwned} />
|
||||
|
||||
{/* ---------------- the ask, in full ---------------- */}
|
||||
<Card title="The request" subtitle="What was handed to Ava">
|
||||
<div className="px-4 py-4">
|
||||
<Facts
|
||||
rows={[
|
||||
{ label: 'Scope', value: str(row.scope) || '—' },
|
||||
{ label: 'Payer', value: str(row.payer) || 'All' },
|
||||
{ label: 'Department', value: str(row.department) || 'All' },
|
||||
{ label: 'Look-back', value: row.period_weeks ? `${str(row.period_weeks)} weeks` : '—' },
|
||||
{ label: 'Question', value: str(row.question) || '—', wide: true },
|
||||
...(str(row.review_note)
|
||||
? [{ label: 'Sent back with', value: str(row.review_note), wide: true }]
|
||||
: []),
|
||||
...(str(row.approval_note)
|
||||
? [{ label: 'Approved with', value: str(row.approval_note), wide: true }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{form && (
|
||||
<ActivityForm
|
||||
open
|
||||
onClose={() => setForm(null)}
|
||||
activityUid={form.activity}
|
||||
instanceId={str(row.instance_id)}
|
||||
title={form.label}
|
||||
subtitle={form.hint}
|
||||
submitLabel={form.label}
|
||||
danger={form.kind === 'danger'}
|
||||
// The approval note opens with a draft grounded in her findings — the
|
||||
// manager edits or replaces it. Typing out numbers already on screen
|
||||
// turns a decision into data entry.
|
||||
prefill={
|
||||
form.activity === ACT.APPROVE
|
||||
? { approval_note: defaultApprovalNote(row) }
|
||||
: undefined
|
||||
}
|
||||
onDone={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The five states, as a rail. Needs Human is off the rail on purpose: it is
|
||||
* not a step forward, it is the case leaving the track. */
|
||||
function Rail({ state }: { state: string }) {
|
||||
if (state === NEEDS_HUMAN) return null;
|
||||
const at = stageStep(state);
|
||||
return (
|
||||
<ol className="mt-4 flex flex-wrap items-center gap-1.5">
|
||||
{STAGES.map((s, i) => {
|
||||
const done = at > i;
|
||||
const here = at === i;
|
||||
return (
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
here
|
||||
? 'bg-brand text-brand-fg'
|
||||
: done
|
||||
? 'bg-good-soft text-good ring-1 ring-good-edge ring-inset'
|
||||
: 'bg-panel2 text-faint'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
{i < STAGES.length - 1 && <span aria-hidden="true" className="h-px w-3 bg-line" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultApprovalNote(row: Record<string, unknown>): string {
|
||||
const recoverable = row.recoverable_amount;
|
||||
return recoverable
|
||||
? `Approved. Proceed with the appeals and the process fix as recommended; expected recovery $${Number(
|
||||
recoverable,
|
||||
).toLocaleString('en-US')}. Re-check when the timer fires.`
|
||||
: 'Approved. Proceed as recommended and re-check when the timer fires.';
|
||||
}
|
||||
71
src/screens/NewInvestigationScreen.tsx
Normal file
71
src/screens/NewInvestigationScreen.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Brain, Database } from 'lucide-react';
|
||||
import { ActivityForm } from '../components/ActivityForm';
|
||||
import { Card } from '../components/core';
|
||||
import { ACT, LAKEHOUSE } from '../api/config';
|
||||
|
||||
/**
|
||||
* Ask Ava a question.
|
||||
*
|
||||
* The form is the INIT activity's own schema, read live from view-service — so
|
||||
* the scope picker, the payer list and the look-back are whatever the workflow
|
||||
* says they are, not a copy maintained here.
|
||||
*
|
||||
* The page around the form exists to set the expectation: the manager is not
|
||||
* running a report, he is handing work to an analyst who will take a few
|
||||
* minutes and come back with a recommendation.
|
||||
*/
|
||||
export function NewInvestigationScreen() {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-ink">Ask Ava</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
Hand her a question about denials. She investigates and comes back with a cause, a
|
||||
dollar figure and a recommendation for you to approve.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card className="px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="rounded-full bg-ai-soft p-2 text-ai ring-1 ring-ai-edge ring-inset">
|
||||
<Brain className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="text-sm text-muted">
|
||||
<p className="font-semibold text-ink">What she will do</p>
|
||||
<ol className="mt-1.5 list-decimal space-y-0.5 pl-4">
|
||||
<li>Reconcile the claims data against the finance report before trusting it</li>
|
||||
<li>Baseline the denial rate, then slice by payer, reason code, department and desk</li>
|
||||
<li>Find where the change concentrates and when it started</li>
|
||||
<li>Look for the cause in the payer's own policy bulletins</li>
|
||||
<li>Count what is still inside the appeal window and what it is worth</li>
|
||||
</ol>
|
||||
<p className="mt-2.5 inline-flex items-center gap-1.5 text-xs text-faint">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
Read-only against {LAKEHOUSE.platform} · {LAKEHOUSE.catalog}.{LAKEHOUSE.schema}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ActivityForm
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
navigate('/q/investigating');
|
||||
}}
|
||||
activityUid={ACT.START}
|
||||
title="Start an investigation"
|
||||
subtitle="Ava picks this up as soon as you submit"
|
||||
submitLabel="Send to Ava"
|
||||
onDone={(r) => {
|
||||
if (r.instance_id) navigate(`/c/${r.instance_id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/screens/QueueScreen.tsx
Normal file
133
src/screens/QueueScreen.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Brain, Clock, User } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import { QUEUES, RECORD_VIEW, STAGE_TONE } from '../api/config';
|
||||
import { Badge, Card, Empty, ErrorNote, Spinner } from '../components/core';
|
||||
import type { Row } from '../api/types';
|
||||
import { dateTime, money, num, str } from '../format';
|
||||
|
||||
/**
|
||||
* One queue — the record view filtered on `current_state_name`.
|
||||
*
|
||||
* POLLS, and that matters more here than in most consoles: a case moves
|
||||
* Investigating → Awaiting Review on its own, driven by an AI employee with no
|
||||
* user action to hang a refresh off. A stale list would look like a stuck
|
||||
* system, and on stage it would look like nothing happened.
|
||||
*/
|
||||
export function QueueScreen() {
|
||||
const { q } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { client } = useZino();
|
||||
const queue = QUEUES.find((x) => x.path === q) ?? QUEUES[0];
|
||||
|
||||
const rows = useQuery(
|
||||
() =>
|
||||
client.rows(RECORD_VIEW, {
|
||||
limit: 100,
|
||||
sortBy: 'updated_at',
|
||||
sortDir: 'desc',
|
||||
filters: queue.stage
|
||||
? [{ field_key: 'current_state_name', value: queue.stage, data_type: 'text' }]
|
||||
: [],
|
||||
}),
|
||||
[queue.path],
|
||||
true,
|
||||
8000,
|
||||
);
|
||||
|
||||
const list = rows.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-ink">{queue.label}</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">{queue.hint}</p>
|
||||
</div>
|
||||
{!rows.loading && <Badge tone="slate">{list.length} case{list.length === 1 ? '' : 's'}</Badge>}
|
||||
</header>
|
||||
|
||||
{rows.error && <ErrorNote>{rows.error}</ErrorNote>}
|
||||
{rows.loading && !rows.data && <Spinner label="Loading cases…" />}
|
||||
|
||||
{rows.data && list.length === 0 && (
|
||||
<Empty>
|
||||
Nothing in this queue.{' '}
|
||||
{queue.stage === 'Awaiting Review' && 'Ava puts her findings here when she is done.'}
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{list.map((r) => (
|
||||
<CaseCard key={str(r.instance_id)} row={r} onOpen={() => navigate(`/c/${str(r.instance_id)}`)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A case as a card rather than a table row.
|
||||
*
|
||||
* A denial investigation is not a record with twelve equal columns — it is one
|
||||
* sentence (what Ava found) and two numbers (what it is worth). A table would
|
||||
* truncate the sentence, which is the only part anyone reads.
|
||||
*/
|
||||
function CaseCard({ row, onOpen }: { row: Row; onOpen: () => void }) {
|
||||
const state = str(row.current_state_name);
|
||||
const tone = STAGE_TONE[state] ?? 'slate';
|
||||
const summary = str(row.summary);
|
||||
const atRisk = num(row.amount_at_risk);
|
||||
const recoverable = num(row.recoverable_amount);
|
||||
const scope = str(row.scope);
|
||||
const question = str(row.question);
|
||||
const aiOwned = state === 'Investigating' || state === 'Monitoring';
|
||||
|
||||
return (
|
||||
<Card className="cursor-pointer transition-colors hover:border-brand/50" >
|
||||
<button onClick={onOpen} className="w-full px-4 py-3.5 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="tnum text-[13px] font-semibold text-ink">
|
||||
Case #{str(row.instance_id)}
|
||||
</span>
|
||||
<Badge tone={tone} dot={aiOwned}>
|
||||
{state}
|
||||
</Badge>
|
||||
{aiOwned ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-ai">
|
||||
<Brain className="h-3 w-3" /> Ava
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-faint">
|
||||
<User className="h-3 w-3" /> with a person
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-[11px] text-faint">
|
||||
<Clock className="h-3 w-3" />
|
||||
{dateTime(row.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 line-clamp-2 text-sm text-ink">
|
||||
{summary || question || `Scope: ${scope || 'all payers'}`}
|
||||
</p>
|
||||
|
||||
{(atRisk || recoverable) && (
|
||||
<div className="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||
{atRisk != null && (
|
||||
<span className="text-xs text-faint">
|
||||
At risk <span className="tnum font-semibold text-ink">{money(atRisk)}</span>
|
||||
</span>
|
||||
)}
|
||||
{recoverable != null && (
|
||||
<span className="text-xs text-faint">
|
||||
Recoverable{' '}
|
||||
<span className="tnum font-semibold text-good">{money(recoverable)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
242
src/styles.css
Normal file
242
src/styles.css
Normal file
@ -0,0 +1,242 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Northlake Health — Denials Desk. Light and dark.
|
||||
=====================================================================
|
||||
HOW THEMING WORKS HERE
|
||||
|
||||
`@theme` below defines the LIGHT theme, and light is also the fallback:
|
||||
every token has a value here, so a document with no `data-theme` renders
|
||||
correctly rather than unstyled. The dark theme is the
|
||||
`:root[data-theme='dark']` block further down, which redefines the SAME
|
||||
token names. Tailwind v4 compiles `bg-panel` to
|
||||
`background-color: var(--color-panel)`, so overriding the variable swaps the
|
||||
entire app — including opacity modifiers like `bg-brand/10`, which compile to
|
||||
a `color-mix()` over the same var.
|
||||
|
||||
Consequently: NEVER put a literal colour in a component. A hex in a className
|
||||
is invisible to the toggle and will be wrong in one of the two themes.
|
||||
|
||||
THE ONE RULE THAT CARRIES THIS APP'S MEANING
|
||||
|
||||
**Violet is the AI, and nothing else is violet.** Every number, sentence and
|
||||
panel that Ava produced is tinted with it; everything a person or a rule
|
||||
produced is not. On a screen whose whole claim is "an agent did this work and
|
||||
you can check it", the reader has to be able to tell the two apart without
|
||||
reading a label. Red is reserved for a case that needs a human and for a
|
||||
reject — so when red appears in the queue it means something.
|
||||
|
||||
Every colour used for text is checked at WCAG AA against the surfaces it
|
||||
actually appears on, in both themes. */
|
||||
@theme {
|
||||
/* --- Structural ---------------------------------------------------- */
|
||||
--color-ink: #0f2233; /* primary text */
|
||||
--color-muted: #4a6076; /* secondary text */
|
||||
--color-faint: #5a7089; /* labels, placeholders, dashes */
|
||||
--color-bg: #f2f7f8; /* page canvas — a cool clinical tint */
|
||||
--color-panel: #ffffff; /* card face */
|
||||
--color-panel2: #f1f6f8; /* input fill, row hover */
|
||||
--color-panel3: #e3eef1; /* pressed / hover on panel2 */
|
||||
--color-line: #d5e4e8; /* borders */
|
||||
|
||||
/* Brand accent — a clinical teal. Used BOTH as a fill and as text, so it
|
||||
carries a companion for whatever sits on top of it. */
|
||||
--color-brand: #0e7c86;
|
||||
--color-brand-fg: #ffffff;
|
||||
--color-brand-hover: #0b636b;
|
||||
|
||||
/* Fixed in both themes: the mark's own colours. */
|
||||
--color-navy: #0e7c86;
|
||||
--color-signal: #c2333a; /* needs-a-human and reject */
|
||||
--color-signal-hover: #9f262c;
|
||||
|
||||
/* --- Semantic tones ------------------------------------------------
|
||||
Six meanings, three roles each: `<tone>` is text, `<tone>-soft` is the chip
|
||||
wash, `<tone>-edge` is the ring. Every `<tone>` passes AA on its own wash
|
||||
AND on the page and panel, because these are used both inside a badge and
|
||||
as bare text (a stat value, a rate). */
|
||||
--color-idle: #55504a;
|
||||
--color-idle-soft: #f2f0ed;
|
||||
--color-idle-edge: #dad4cc;
|
||||
|
||||
/* THE most important tone in this app. Amber is Awaiting Review — the pile a
|
||||
human owns — and it is deliberately the only amber in the pipeline, so it
|
||||
reads as "needs you" without reading as an error. Findings waiting for a
|
||||
decision are not a problem; they are the product. */
|
||||
--color-warn: #8a5a00;
|
||||
--color-warn-soft: #fff8e8;
|
||||
--color-warn-edge: #f0c368;
|
||||
|
||||
--color-info: #0f5c78;
|
||||
--color-info-soft: #e7f2f6;
|
||||
--color-info-edge: #a4cadb;
|
||||
|
||||
--color-good: #00713d;
|
||||
--color-good-soft: #e4f6ec;
|
||||
--color-good-edge: #93d3b0;
|
||||
|
||||
--color-bad: #b31e23;
|
||||
--color-bad-soft: #fdeced;
|
||||
--color-bad-edge: #eda9ac;
|
||||
|
||||
/* The AI's own colour — see the rule at the top. */
|
||||
--color-ai: #5b2ca0;
|
||||
--color-ai-soft: #f5f1fd;
|
||||
--color-ai-edge: #c3adec;
|
||||
|
||||
--color-scrim: rgb(6 28 40 / 0.45);
|
||||
|
||||
/* Inter: a screen typeface for dense tabular work, self-hosted through
|
||||
@fontsource-variable/inter (imported in main.tsx) rather than a CDN — this
|
||||
build is promoted between environments and dropped behind hospital
|
||||
proxies, and a font fetched from fonts.googleapis.com is one that
|
||||
sometimes does not arrive. */
|
||||
--font-sans:
|
||||
'Inter Variable', Inter, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
/* Non-Tailwind vars: consumed by the hand-rolled rules at the bottom rather
|
||||
than by generated utilities, so they live outside `@theme`. */
|
||||
:root {
|
||||
/* Tinted with navy rather than black, so the lift reads as part of the
|
||||
palette. In dark it goes near-black and deeper — a navy shadow on a
|
||||
navy canvas is invisible, so dark leans on the border instead. */
|
||||
--lift: 0 1px 2px rgb(6 40 52 / 0.07), 0 1px 1px rgb(6 40 52 / 0.04);
|
||||
--lift-lg: 0 12px 32px rgb(6 40 52 / 0.16), 0 2px 8px rgb(6 40 52 / 0.08);
|
||||
--sb-thumb: #c4d8dd;
|
||||
--sb-thumb-hover: #a6c2c9;
|
||||
/* Makes the browser render native widgets — date picker, select dropdown,
|
||||
scrollbar gutter — to match. Without it a dark console opens a blinding
|
||||
white calendar. */
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
DARK — the navy-led palette. Only tokens are redefined; no utility,
|
||||
component or layout rule is duplicated.
|
||||
===================================================================== */
|
||||
:root[data-theme='dark'] {
|
||||
--color-ink: #e7f1f5;
|
||||
--color-muted: #a3c0cc;
|
||||
--color-faint: #93b2c0;
|
||||
--color-bg: #07161f;
|
||||
--color-panel: #10233a;
|
||||
--color-panel2: #17304a;
|
||||
--color-panel3: #1f3d59;
|
||||
--color-line: #22405c;
|
||||
|
||||
/* The accent lightens and the text on it darkens — the inverse of light. A
|
||||
#0E7C86 fill would sink into the canvas, and #0E7C86 text on it is
|
||||
unreadable. */
|
||||
--color-brand: #4fc3cd;
|
||||
--color-brand-fg: #06202a;
|
||||
--color-brand-hover: #6fd2da;
|
||||
|
||||
/* The signal red is unchanged: white on it clears AA either way, and it
|
||||
still clears 3:1 against the dark canvas as a large component. */
|
||||
--color-signal-hover: #e8686e;
|
||||
|
||||
--color-idle: #d6cdbf;
|
||||
--color-idle-soft: #2a2724;
|
||||
--color-idle-edge: #4b453b;
|
||||
|
||||
--color-warn: #f7cd7a;
|
||||
--color-warn-soft: #3a2a06;
|
||||
--color-warn-edge: #8a681f;
|
||||
|
||||
--color-info: #9fd0e2;
|
||||
--color-info-soft: #0d3a4c;
|
||||
--color-info-edge: #3f7f99;
|
||||
|
||||
--color-good: #5fd694;
|
||||
--color-good-soft: #052a18;
|
||||
--color-good-edge: #10683b;
|
||||
|
||||
--color-bad: #f4a3a6;
|
||||
--color-bad-soft: #3c1416;
|
||||
--color-bad-edge: #8c3a3d;
|
||||
|
||||
--color-ai: #c9b3f2;
|
||||
--color-ai-soft: #221542;
|
||||
--color-ai-edge: #5a3fa2;
|
||||
|
||||
--color-scrim: rgb(2 10 14 / 0.66);
|
||||
|
||||
--lift: 0 1px 2px rgb(0 0 0 / 0.4);
|
||||
--lift-lg: 0 16px 40px rgb(0 0 0 / 0.55), 0 2px 8px rgb(0 0 0 / 0.4);
|
||||
--sb-thumb: #1e4a5e;
|
||||
--sb-thumb-hover: #2a6376;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Tabular figures everywhere numbers line up in a column — amounts,
|
||||
ratios, file numbers. Proportional digits make a money table look
|
||||
ragged, and this app is mostly money. */
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--sb-thumb);
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--sb-thumb-hover);
|
||||
}
|
||||
|
||||
@keyframes nl-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.live-dot {
|
||||
animation: nl-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lift {
|
||||
box-shadow: var(--lift);
|
||||
}
|
||||
.lift-lg {
|
||||
box-shadow: var(--lift-lg);
|
||||
}
|
||||
|
||||
/* A SQL statement, as Ava sent it. Monospace, wrapped rather than scrolled —
|
||||
these are read, not edited, and a horizontal scrollbar inside a timeline row
|
||||
hides the end of the query, which is usually the interesting part. */
|
||||
.sql {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
106
src/theme.ts
Normal file
106
src/theme.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Light / dark theme, persisted per browser.
|
||||
*
|
||||
* WHAT THE SWITCH ACTUALLY DOES
|
||||
*
|
||||
* Sets `data-theme` on <html>. Nothing else. Every colour in the app is a
|
||||
* CSS custom property that `styles.css` redefines under
|
||||
* `:root[data-theme='dark']`, so one attribute swaps the whole console —
|
||||
* there is no second stylesheet, no per-component dark variant, and no
|
||||
* `dark:` prefix anywhere in the codebase.
|
||||
*
|
||||
* FIRST VISIT FOLLOWS THE OPERATING SYSTEM
|
||||
*
|
||||
* A desk that runs dark everywhere else should not open blinding white,
|
||||
* and one that runs light should not open dark. So with no stored choice
|
||||
* we take `prefers-color-scheme`, and we keep tracking it — but an
|
||||
* EXPLICIT choice wins permanently and stops listening. Someone who
|
||||
* deliberately picked light at 3am does not want the OS flipping them at
|
||||
* sunrise.
|
||||
*/
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
/** Also read by the inline script in index.html. If you rename this, rename
|
||||
* it there too — see the note in that file about why it is duplicated. */
|
||||
export const THEME_KEY = 'northlake-denials-theme';
|
||||
|
||||
function isTheme(v: unknown): v is Theme {
|
||||
return v === 'light' || v === 'dark';
|
||||
}
|
||||
|
||||
/** The user's explicit choice, or null if they have never chosen. */
|
||||
export function storedTheme(): Theme | null {
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_KEY);
|
||||
return isTheme(v) ? v : null;
|
||||
} catch {
|
||||
// Private mode / blocked storage. Not a reason to fail to render — the
|
||||
// theme just stops persisting across reloads.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function systemTheme(): Theme {
|
||||
return typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
|
||||
export function resolveTheme(): Theme {
|
||||
return storedTheme() ?? systemTheme();
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
// Keep the mobile browser chrome in step with the header it sits above —
|
||||
// --color-panel in both themes. Keep these two literals in step with
|
||||
// `--color-panel` in styles.css and with the inline script in index.html.
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', theme === 'dark' ? '#10233a' : '#ffffff');
|
||||
}
|
||||
|
||||
export function useTheme(): { theme: Theme; setTheme: (t: Theme) => void; toggle: () => void } {
|
||||
const [theme, setThemeState] = useState<Theme>(resolveTheme);
|
||||
|
||||
// Reconciles React with whatever the index.html inline script already put
|
||||
// on <html>. On first mount these agree; this exists for the case where
|
||||
// they cannot, e.g. storage threw in one place and not the other.
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Track the OS only while the user has made no explicit choice.
|
||||
useEffect(() => {
|
||||
if (storedTheme()) return;
|
||||
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!mq) return;
|
||||
const onChange = (e: MediaQueryListEvent) => setThemeState(e.matches ? 'dark' : 'light');
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
} catch {
|
||||
// Unpersisted is still switchable for this session.
|
||||
}
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme(resolveThemeOpposite());
|
||||
}, [setTheme]);
|
||||
|
||||
return { theme, setTheme, toggle };
|
||||
}
|
||||
|
||||
/** Read from the DOM rather than from React state so a rapid double-click
|
||||
* cannot toggle off a stale value. */
|
||||
function resolveThemeOpposite(): Theme {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
14
tsconfig.node.json
Normal file
14
tsconfig.node.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// Relative base: emitted asset refs become "./assets/…", so the <base href> the
|
||||
// server writes at placement decides where they resolve. One build therefore
|
||||
// serves any mount path. Do NOT reintroduce a build-time base — the artifact is
|
||||
// promoted between environments unchanged and would stop being placeable.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: './',
|
||||
server: { port: 5174 },
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user