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:
Yashas 2026-09-15 23:47:19 +05:30
commit 1c1c120202
32 changed files with 5735 additions and 0 deletions

5
.env.example Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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>;
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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.';
}

View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.app.json Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

14
tsconfig.node.json Normal file
View 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
View 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 },
});