feat: claims explorer — the lakehouse rows behind every number

- /claims: Databricks claims table through the record-view API, server-side
  paging and filters (exact on dimensions, ranges on dates); URL is the filter set
- finding tiles link to the exact rows: affected set, and the unfiled-inside-window
  subset behind 'still recoverable'; kind-aware (expiring, slow payer, underpayment)
- 'What Ava reviewed' work statement shown under the headline
- client: range / multi-value filters

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-16 18:45:18 +05:30
parent 1d2dc4a1b9
commit e8741f6333
9 changed files with 421 additions and 13 deletions

View File

@ -5,6 +5,7 @@ import { Login } from './pages/Login';
import { QueueScreen } from './screens/QueueScreen'; import { QueueScreen } from './screens/QueueScreen';
import { CaseScreen } from './screens/CaseScreen'; import { CaseScreen } from './screens/CaseScreen';
import { RunReviewScreen } from './screens/RunReviewScreen'; import { RunReviewScreen } from './screens/RunReviewScreen';
import { ClaimsScreen } from './screens/ClaimsScreen';
/** /**
* Routes. * Routes.
@ -25,6 +26,7 @@ export function App() {
<Route path="/q/:q" element={<QueueScreen />} /> <Route path="/q/:q" element={<QueueScreen />} />
<Route path="/c/:id" element={<CaseScreen />} /> <Route path="/c/:id" element={<CaseScreen />} />
<Route path="/review" element={<RunReviewScreen />} /> <Route path="/review" element={<RunReviewScreen />} />
<Route path="/claims" element={<ClaimsScreen />} />
{/* Awaiting Review is the landing page, not "all cases": it is the {/* 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. */} 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 />} />

View File

@ -224,5 +224,24 @@ export async function loadBriefing(
}; };
} }
/** The date the lakehouse views are anchored on: the last service week's
* Sunday, or the replay date. Deep links into the claims table use it for
* "still inside the appeal window" and "expiring within 14 days". */
export function anchorDate(data: BriefingData | null | undefined): string | null {
if (!data) return null;
if (data.asOf) return data.asOf;
const last = data.weeklyRate[data.weeklyRate.length - 1]?.week;
if (!last) return null;
const d = new Date(last + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 6);
return d.toISOString().slice(0, 10);
}
export function addDays(iso: string, days: number): string {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
/** Ordered by how alive the money is. Fixed order → fixed colour per bucket. */ /** Ordered by how alive the money is. Fixed order → fixed colour per bucket. */
export const RECOVERY_ORDER = ['recovered', 'appeal filed', 'still appealable', 'lost on appeal', 'window closed']; export const RECOVERY_ORDER = ['recovered', 'appeal filed', 'still appealable', 'lost on appeal', 'window closed'];

View File

@ -141,9 +141,11 @@ export class ZinoClient {
search: params.search ?? '', search: params.search ?? '',
filters: (params.filters ?? []).map((f) => ({ filters: (params.filters ?? []).map((f) => ({
field_key: f.field_key, field_key: f.field_key,
value: f.value, value: f.value ?? '',
value2: '', value2: '',
data_type: f.data_type ?? 'string', ...(f.values ? { values: f.values } : {}),
...(f.range ? { range: f.range } : {}),
data_type: f.data_type ?? (f.range ? 'date' : 'string'),
})), })),
}, },
}); });

View File

@ -45,13 +45,23 @@ export interface RecordViewResponse {
/** A record-view row. Values are whatever the column's data_type implies. */ /** A record-view row. Values are whatever the column's data_type implies. */
export type Row = Record<string, unknown>; export type Row = Record<string, unknown>;
/** One record-view filter. `value` is exact match; `values` matches any;
* `range` (with data_type 'date') is an inclusive, open-endable bound. */
export interface RecordFilter {
field_key: string;
value?: string;
values?: string[];
range?: { from?: string; to?: string };
data_type?: string;
}
export interface RecordViewParams { export interface RecordViewParams {
page?: number; page?: number;
limit?: number; limit?: number;
sortBy?: string; sortBy?: string;
sortDir?: 'asc' | 'desc'; sortDir?: 'asc' | 'desc';
search?: string; search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>; filters?: RecordFilter[];
} }
export interface DetailViewResponse { export interface DetailViewResponse {

View File

@ -1,5 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { AlertTriangle, CheckCircle2, ChevronDown, FileSearch } from 'lucide-react'; import { Link } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
import type { Slice } from '../api/charts';
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
import { Badge, Card, Facts } from './core'; import { Badge, Card, Facts } from './core';
import type { AvaDecision, AvaTraceStep, Row } from '../api/types'; import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
import { import {
@ -30,10 +33,23 @@ import {
*/ */
/** The headline card: one sentence, and the three numbers. */ /** The headline card: one sentence, and the three numbers. */
export function Verdict({ row }: { row: Row }) { export function Verdict({
row,
slice = null,
kind = '',
anchor = null,
}: {
row: Row;
slice?: Slice | null;
kind?: string;
anchor?: string | null;
}) {
const summary = str(row.summary); const summary = str(row.summary);
if (!summary) return null; if (!summary) return null;
const line = headline(summary); const line = headline(summary);
const scanned = str(row.scanned_summary);
const affectedHref = affectedClaimsHref(kind, slice, anchor);
const recoverableHref = recoverableClaimsHref(kind, slice, anchor);
const atRisk = num(row.amount_at_risk); const atRisk = num(row.amount_at_risk);
const recoverable = num(row.recoverable_amount); const recoverable = num(row.recoverable_amount);
const affected = num(row.affected_claims); const affected = num(row.affected_claims);
@ -58,15 +74,26 @@ export function Verdict({ row }: { row: Row }) {
)} )}
<span className="ml-auto">AI analysis · Ava</span> <span className="ml-auto">AI analysis · Ava</span>
</div> </div>
{scanned && (
<p className="mt-3 border-t border-ai-edge/60 pt-3 text-[13px] leading-relaxed text-muted">
<span className="font-semibold text-ink">What Ava reviewed. </span>
{scanned}
</p>
)}
</div> </div>
<div className="grid gap-px bg-line sm:grid-cols-3"> <div className="grid gap-px bg-line sm:grid-cols-3">
<Big label="Claims denied" value={affected == null ? '—' : affected.toLocaleString('en-US')} /> <Big
label={kind === 'slow_payer' ? 'Claims unanswered' : kind === 'underpayment' ? 'Claims short-paid' : 'Claims denied'}
value={affected == null ? '—' : affected.toLocaleString('en-US')}
href={affectedHref}
/>
<Big label="Money at stake" value={money(atRisk)} tone="bad" /> <Big label="Money at stake" value={money(atRisk)} tone="bad" />
<Big <Big
label="Still recoverable" label="Still recoverable"
value={money(recoverable)} value={money(recoverable)}
tone="good" tone="good"
href={recoverableHref}
note={ note={
deadline deadline
? `if appeals are filed — first deadline ${shortDate(deadline)}` ? `if appeals are filed — first deadline ${shortDate(deadline)}`
@ -247,14 +274,27 @@ export function Recheck({ row }: { row: Row }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function Big({ label, value, tone, note }: { label: string; value: string; tone?: 'good' | 'bad'; note?: string }) { function Big({ label, value, tone, note, href }: { label: string; value: string; tone?: 'good' | 'bad'; note?: string; href?: string | null }) {
const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink'; const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink';
return ( const body = (
<div className="bg-panel px-5 py-4"> <>
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</p> <p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</p>
<p className={`tnum mt-1 text-[28px] leading-none font-bold ${text}`}>{value}</p> <p className={`tnum mt-1 text-[28px] leading-none font-bold ${text}`}>{value}</p>
{note && <p className="mt-1.5 text-xs text-muted">{note}</p>} {note && <p className="mt-1.5 text-xs text-muted">{note}</p>}
</div> {href && (
<p className="mt-1.5 inline-flex items-center gap-1 text-xs text-brand">
See the claims <ExternalLink className="h-3 w-3" />
</p>
)}
</>
);
// A number you can click opens the rows behind it in the claims table.
return href ? (
<Link to={href} className="block bg-panel px-5 py-4 transition-colors hover:bg-panel2/60" title="Open these claims">
{body}
</Link>
) : (
<div className="bg-panel px-5 py-4">{body}</div>
); );
} }

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { LogOut, Radar } from 'lucide-react'; import { Database, LogOut, Radar } from 'lucide-react';
import { useZino } from '../api/provider'; import { useZino } from '../api/provider';
import { QUEUES, prettyRole } from '../api/config'; import { QUEUES, prettyRole } from '../api/config';
import { NorthlakeLockup } from '../components/NorthlakeLogo'; import { NorthlakeLockup } from '../components/NorthlakeLogo';
@ -90,6 +90,28 @@ export function Shell() {
</NavLink> </NavLink>
</li> </li>
))} ))}
{/* The data itself, after the pipeline: every claim in the lakehouse. */}
<li className="shrink-0 lg:mt-3 lg:shrink lg:border-t lg:border-line lg:pt-3">
<NavLink
to="/claims"
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="inline-flex items-center gap-1.5 text-[13px] font-semibold whitespace-nowrap">
<Database className="h-3.5 w-3.5" /> Claims
</span>
<span className={`hidden text-[11px] lg:block ${isActive ? 'text-brand-fg/70' : 'text-faint'}`}>
The lakehouse, row by row
</span>
</>
)}
</NavLink>
</li>
</ul> </ul>
</nav> </nav>

62
src/lib/claimsLink.ts Normal file
View File

@ -0,0 +1,62 @@
import type { Slice } from '../api/charts';
import { addDays, isDenialKind } from '../api/charts';
/**
* Deep links from a finding into the claims table.
*
* Ava writes "216 claims" and "172 still appealable"; these build the
* /claims URL whose filters reproduce those rows. Every filter here is a
* column the record view declares (11_claims_explorer.sql), and the date
* bounds compile to inclusive ranges on the lakehouse. The anchor is the
* data's latest date (or the replay date) — without it we still link, just
* without the "inside the window" bound.
*/
export type ClaimsQuery = Partial<Record<
'payer' | 'department' | 'code' | 'category' | 'status' | 'appeal' | 'site' | 'desk' | 'provider' | 'cpt'
| 'from' | 'to' | 'sub_from' | 'sub_to' | 'dl_from' | 'dl_to' | 'page',
string
>>;
export function claimsHref(q: ClaimsQuery): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(q)) if (v) p.set(k, v);
const s = p.toString();
return s ? `/claims?${s}` : '/claims';
}
/** The rows behind "claims denied" / "affected". */
export function affectedClaimsHref(kind: string, slice: Slice | null, anchor: string | null): string | null {
if (kind === 'expiring_appeals') {
return claimsHref({ status: 'denied', appeal: 'not_filed', dl_from: anchor ?? undefined, dl_to: anchor ? addDays(anchor, 14) : undefined });
}
if (kind === 'slow_payer') {
return slice?.payer ? claimsHref({ payer: slice.payer, status: 'pending' }) : null;
}
if (kind === 'underpayment') {
return slice?.payer ? claimsHref({ payer: slice.payer, status: 'partial', from: slice.spikeStart ?? undefined }) : null;
}
if (!slice || !isDenialKind(kind)) return null;
return claimsHref({
payer: slice.payer || undefined,
department: slice.department || undefined,
code: slice.code || undefined,
status: 'denied',
from: slice.spikeStart ?? undefined,
});
}
/** The rows behind "still recoverable": the affected set, unfiled and inside its window. */
export function recoverableClaimsHref(kind: string, slice: Slice | null, anchor: string | null): string | null {
if (kind === 'expiring_appeals') return affectedClaimsHref(kind, slice, anchor);
if (kind === 'slow_payer' || kind === 'underpayment') return null;
if (!slice || !isDenialKind(kind)) return null;
return claimsHref({
payer: slice.payer || undefined,
department: slice.department || undefined,
code: slice.code || undefined,
status: 'denied',
appeal: 'not_filed',
from: slice.spikeStart ?? undefined,
dl_from: anchor ?? undefined,
});
}

View File

@ -17,7 +17,7 @@ import { ActivityForm } from '../components/ActivityForm';
import { AvaLive } from '../components/AvaLive'; import { AvaLive } from '../components/AvaLive';
import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings'; import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings';
import { Briefing, BriefingSkeleton } from '../components/Briefing'; import { Briefing, BriefingSkeleton } from '../components/Briefing';
import { loadBriefing, sliceOf } from '../api/charts'; import { anchorDate, loadBriefing, sliceOf } from '../api/charts';
import type { Action } from '../api/config'; import type { Action } from '../api/config';
import { dateTime, num, str } from '../format'; import { dateTime, num, str } from '../format';
@ -171,7 +171,7 @@ export function CaseScreen() {
{/* ---------- The findings: what happened, how much, what to approve ---------- */} {/* ---------- The findings: what happened, how much, what to approve ---------- */}
{hasFindings && ( {hasFindings && (
<> <>
<Verdict row={row} /> <Verdict row={row} slice={slice} kind={kind} anchor={anchorDate(briefing.data)} />
{briefing.data ? ( {briefing.data ? (
<Briefing data={briefing.data} slice={slice} kind={kind} /> <Briefing data={briefing.data} slice={slice} kind={kind} />
) : ( ) : (

View File

@ -0,0 +1,251 @@
import { useMemo } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Database, X } from 'lucide-react';
import { useQuery, useZino } from '../api/provider';
import { LAKEHOUSE } from '../api/config';
import type { Tone } from '../api/config';
import type { RecordFilter, Row } from '../api/types';
import { Badge, ErrorNote, Spinner } from '../components/core';
import { CARC, money, num, shortDate, str } from '../format';
/**
* The claims table — the lakehouse rows themselves, read-only.
*
* Reached two ways: from the sidebar (unfiltered, newest first) and from a
* finding's numbers ("216 claims" → the exact rows Ava counted). The URL IS
* the filter set, so a link can be copied into an email and lands on the same
* rows. Everything is server-side: paging, filtering and the total come from
* /view/recordview against the Databricks-backed record view.
*/
const PAGE = 50;
/** URL param → record-view column, with the control shape it compiles to. */
const DIMS: Array<{ key: string; col: string; label: string }> = [
{ key: 'payer', col: 'payer', label: 'Insurer' },
{ key: 'department', col: 'department', label: 'Department' },
{ key: 'code', col: 'denial_reason_code', label: 'Reason code' },
{ key: 'category', col: 'denial_category', label: 'Denial category' },
{ key: 'status', col: 'status', label: 'Status' },
{ key: 'appeal', col: 'appeal_status', label: 'Appeal' },
{ key: 'site', col: 'site', label: 'Site' },
{ key: 'desk', col: 'registration_desk', label: 'Desk' },
{ key: 'provider', col: 'provider', label: 'Provider' },
{ key: 'cpt', col: 'procedure_code', label: 'CPT' },
];
const RANGES: Array<{ from: string; to: string; col: string; label: string }> = [
{ from: 'from', to: 'to', col: 'service_date', label: 'Service date' },
{ from: 'sub_from', to: 'sub_to', col: 'submitted_date', label: 'Submitted' },
{ from: 'dl_from', to: 'dl_to', col: 'appeal_deadline', label: 'Appeal deadline' },
];
const STATUS_TONE: Record<string, Tone> = {
paid: 'emerald',
partial: 'amber',
denied: 'rose',
pending: 'slate',
};
const APPEAL_LABEL: Record<string, string> = {
not_filed: 'not filed',
filed: 'filed',
overturned: 'overturned',
upheld: 'upheld',
};
export function ClaimsScreen() {
const { client } = useZino();
const [params] = useSearchParams();
const navigate = useNavigate();
const page = Math.max(1, Number(params.get('page') ?? '1') || 1);
// The filter set, in the order the chips show it.
const chips = useMemo(() => {
const out: Array<{ label: string; value: string; remove: string[] }> = [];
for (const d of DIMS) {
const v = params.get(d.key);
if (v) out.push({ label: d.label, value: d.col === 'appeal_status' ? (APPEAL_LABEL[v] ?? v) : v, remove: [d.key] });
}
for (const r of RANGES) {
const a = params.get(r.from);
const b = params.get(r.to);
if (a || b) out.push({ label: r.label, value: a && b ? `${shortDate(a)} – ${shortDate(b)}` : a ? `from ${shortDate(a)}` : `to ${shortDate(b)}`, remove: [r.from, r.to] });
}
return out;
}, [params]);
const filters = useMemo<RecordFilter[]>(() => {
const f: RecordFilter[] = [];
for (const d of DIMS) {
const v = params.get(d.key);
if (v) f.push({ field_key: d.col, value: v, data_type: 'string' });
}
for (const r of RANGES) {
const a = params.get(r.from) ?? undefined;
const b = params.get(r.to) ?? undefined;
if (a || b) f.push({ field_key: r.col, data_type: 'date', range: { from: a, to: b } });
}
return f;
}, [params]);
const key = params.toString();
const q = useQuery(
() => client.recordView('dni-rv-claims', { page, limit: PAGE, filters, sortBy: 'service_date', sortDir: 'desc' }),
[key],
);
const rows: Row[] = q.data?.records ?? q.data?.data ?? [];
const total = q.data?.total ?? null;
const pages = total != null ? Math.max(1, Math.ceil(total / PAGE)) : null;
const billedOnPage = rows.reduce((t, r) => t + (num(r.billed_amount) ?? 0), 0);
function go(next: URLSearchParams) {
navigate(`/claims${next.toString() ? `?${next}` : ''}`);
}
function drop(keys: string[]) {
const next = new URLSearchParams(params);
for (const k of keys) next.delete(k);
next.delete('page');
go(next);
}
function setPage(n: number) {
const next = new URLSearchParams(params);
if (n <= 1) next.delete('page');
else next.set('page', String(n));
go(next);
}
return (
<div className="mx-auto max-w-6xl space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-xl font-semibold text-ink">Claims</h1>
<p className="mt-0.5 inline-flex items-center gap-1.5 text-xs text-faint">
<Database className="h-3.5 w-3.5" />
Read-only · {LAKEHOUSE.platform} · {LAKEHOUSE.catalog}.{LAKEHOUSE.schema}.claims
</p>
</div>
<p className="tnum text-sm text-muted">
{total == null ? '' : `${total.toLocaleString('en-US')} claim${total === 1 ? '' : 's'}`}
{rows.length > 0 && total != null && total > rows.length ? ` · ${money(billedOnPage)} billed on this page` : rows.length > 0 ? ` · ${money(billedOnPage)} billed` : ''}
</p>
</div>
{chips.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{chips.map((c) => (
<button
key={c.label}
onClick={() => drop(c.remove)}
title="Remove filter"
className="inline-flex items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1 text-xs text-ink hover:border-brand/60"
>
<span className="text-faint">{c.label}</span>
<span className="font-semibold">{c.value}</span>
<X className="h-3 w-3 text-faint" />
</button>
))}
<Link to="/claims" className="text-xs text-muted hover:text-ink">
Clear all
</Link>
</div>
)}
{q.error && <ErrorNote>{q.error}</ErrorNote>}
<div className="lift overflow-hidden rounded-xl border border-line bg-panel">
{q.loading && !q.data ? (
<Spinner label="Reading the lakehouse…" />
) : rows.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-faint">No claims match these filters.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-left text-[11px] font-semibold tracking-wide text-faint uppercase">
<Th>Claim</Th>
<Th>Service</Th>
<Th>Insurer</Th>
<Th>Department</Th>
<Th>Provider</Th>
<Th>Procedure</Th>
<Th right>Billed</Th>
<Th>Status</Th>
<Th>Reason</Th>
<Th>Appeal</Th>
<Th>Deadline</Th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{rows.map((r) => {
const status = str(r.status);
const code = str(r.denial_reason_code);
return (
<tr key={str(r.claim_id)} className="hover:bg-panel2/60">
<Td mono>{str(r.claim_id)}</Td>
<Td>{shortDate(str(r.service_date).slice(0, 10) || null)}</Td>
<Td>{str(r.payer)}</Td>
<Td>{str(r.department)}</Td>
<Td>{str(r.provider)}</Td>
<Td>
<span className="tnum text-faint">{str(r.procedure_code)}</span> {str(r.procedure_desc)}
</Td>
<Td right mono>{money(r.billed_amount)}</Td>
<Td>
<Badge tone={STATUS_TONE[status] ?? 'slate'}>{status}</Badge>
</Td>
<Td>
{code ? (
<span title={CARC[code] ?? str(r.denial_reason_desc)}>
<span className="tnum font-semibold">{code}</span>{' '}
<span className="text-muted">{str(r.denial_reason_desc)}</span>
</span>
) : (
<span className="text-faint">—</span>
)}
</Td>
<Td>{status === 'denied' ? (APPEAL_LABEL[str(r.appeal_status)] ?? str(r.appeal_status)) : <span className="text-faint">—</span>}</Td>
<Td>{status === 'denied' ? shortDate(str(r.appeal_deadline).slice(0, 10) || null) : <span className="text-faint">—</span>}</Td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{pages != null && pages > 1 && (
<div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted">
<span className="tnum">
Page {page} of {pages.toLocaleString('en-US')}
</span>
<span className="inline-flex gap-1">
<PageBtn disabled={page <= 1} onClick={() => setPage(page - 1)} label="Previous page">
<ChevronLeft className="h-4 w-4" />
</PageBtn>
<PageBtn disabled={page >= pages} onClick={() => setPage(page + 1)} label="Next page">
<ChevronRight className="h-4 w-4" />
</PageBtn>
</span>
</div>
)}
</div>
</div>
);
}
function Th({ children, right }: { children: React.ReactNode; right?: boolean }) {
return <th className={`border-b border-line px-3 py-2.5 ${right ? 'text-right' : ''}`}>{children}</th>;
}
function Td({ children, right, mono }: { children: React.ReactNode; right?: boolean; mono?: boolean }) {
return <td className={`px-3 py-2 align-top text-ink ${right ? 'text-right' : ''} ${mono ? 'tnum' : ''}`}>{children}</td>;
}
function PageBtn({ children, disabled, onClick, label }: { children: React.ReactNode; disabled: boolean; onClick: () => void; label: string }) {
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className="rounded-md border border-line p-1 text-muted hover:bg-panel2 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
>
{children}
</button>
);
}