- rpa-dashboard-ai/SKILL.md: trigger description and the shared question flow; points at the no-AI frontend.md, themes and palette instead of copying them. - templates/contract.md: runs, review rows, endpoints and the server approve gate. - Superbill extracts: patient names and the client name removed, every file carries an EXAMPLE ONLY banner. tests/test_reference_scan.py checks both skills for patient data, internal IPs, credentials and keys. - tests/fixtures/d-ai-extraction and its eval entry, which runs page_check_ai on the built folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
|
import { useState } from 'react'
|
|
import type { ReactElement } from 'react'
|
|
import type { BatchStage, BoardTab } from '../types'
|
|
import type { Project } from '../project'
|
|
import { PROJECT_LABEL } from '../project'
|
|
import { IconSpend, IconPage, IconLoop, IconIdCard, IconPatients } from './icons'
|
|
|
|
/**
|
|
* The app-wide section rail, at a fixed 236px.
|
|
*
|
|
* It used to collapse to a 56px icon strip and expand to an overlay on hover.
|
|
* That is gone with the reskin: the rail is now always labelled, which is what
|
|
* the approved mockup shows and what removes the "hover to find out what these
|
|
* icons are" step from every navigation.
|
|
*
|
|
* Two projects, one rail: ECW's boards (templates 1-6) and Tebra's (template 7
|
|
* / Example Client), each under its own group header. See project.ts for how a
|
|
* batch or run is assigned to one or the other.
|
|
*
|
|
* Selection and drag-and-drop-to-move-stage behaviour are unchanged — only the
|
|
* shape of the control moved.
|
|
*/
|
|
export function Sidebar({
|
|
tab,
|
|
onTab,
|
|
counts,
|
|
onDropBatch,
|
|
onDropRun,
|
|
isAdmin,
|
|
projects,
|
|
}: {
|
|
tab: BoardTab
|
|
onTab: (t: BoardTab) => void
|
|
/** Per project, per stage — each section shows its own queue depth. */
|
|
counts: Record<Project, Record<BatchStage, number>>
|
|
/** A batch card dragged onto a section moves to that stage. */
|
|
onDropBatch: (batchId: string, stage: BatchStage) => void
|
|
/** A single document dragged onto Automation is gathered into its own batch. */
|
|
onDropRun?: (jobId: string) => void
|
|
/** Gates each section's ROI item, and the single account-level People
|
|
* item below them all — both are admin-and-above. */
|
|
isAdmin: boolean
|
|
/** Which sections render at all. A scoped admin sees one; everyone else sees
|
|
* both. Independent of `isAdmin`: an operator sees both sections' boards
|
|
* with ROI/People hidden inside each, while a scoped admin has a whole
|
|
* section absent regardless of role. */
|
|
projects: Project[]
|
|
}) {
|
|
return (
|
|
<nav
|
|
aria-label="Sections"
|
|
className="sb-rail z-10 w-[236px] shrink-0 overflow-y-auto"
|
|
>
|
|
{projects.includes('ecw') && (
|
|
<>
|
|
<SectionHead project="ecw" first={projects[0] === 'ecw'} />
|
|
{/* First, and deliberately not a drop target: ROI is a report over the
|
|
whole pipeline, not a place a batch can be put. Passing no
|
|
`dropStage` is what makes dropping onto it impossible, rather than
|
|
a handler that quietly does nothing. */}
|
|
{isAdmin && (
|
|
<Item
|
|
tab="roi"
|
|
icon={IconSpend}
|
|
label="ROI Dashboard"
|
|
hint="What the pipeline has cost, and what that bought"
|
|
active={tab === 'roi'}
|
|
onSelect={onTab}
|
|
/>
|
|
)}
|
|
<Item
|
|
tab="extraction"
|
|
dropStage="extraction"
|
|
dropTab="extraction"
|
|
icon={IconPage}
|
|
label="Charge Extraction"
|
|
hint="Drop PDFs, run the extractor, clear the flagged fields"
|
|
active={tab === 'extraction'}
|
|
n={counts.ecw.extraction}
|
|
onSelect={onTab}
|
|
onDropBatch={onDropBatch}
|
|
onDropRun={onDropRun}
|
|
/>
|
|
{/* Demography's own spend, apart from the charges ROI above (ticket
|
|
05). Admin only, the same rule as the other ROI items. */}
|
|
{isAdmin && (
|
|
<Item
|
|
tab="demography-roi"
|
|
icon={IconSpend}
|
|
label="Demography ROI"
|
|
hint="What demography extraction has cost, and what that bought"
|
|
active={tab === 'demography-roi'}
|
|
onSelect={onTab}
|
|
/>
|
|
)}
|
|
<Item
|
|
tab="demography"
|
|
icon={IconIdCard}
|
|
label="Demography Extraction"
|
|
hint="OCR-only extraction for facesheets and admission records"
|
|
active={tab === 'demography'}
|
|
onSelect={onTab}
|
|
/>
|
|
<Item
|
|
tab="automation"
|
|
dropStage="automation"
|
|
dropTab="automation"
|
|
icon={IconLoop}
|
|
label="Charge Automation"
|
|
hint="Batches that finished extraction and are ready to post"
|
|
active={tab === 'automation'}
|
|
n={counts.ecw.automation}
|
|
onSelect={onTab}
|
|
onDropBatch={onDropBatch}
|
|
onDropRun={onDropRun}
|
|
/>
|
|
{/* Not a drop target, same reasoning as Demography Extraction above:
|
|
a demography run is not a batch, so BATCH_DRAG_TYPE's payload
|
|
(a batch_id) has nowhere to go here. */}
|
|
<Item
|
|
tab="demography-automation"
|
|
icon={IconIdCard}
|
|
label="Demography Automation"
|
|
hint="Demography runs that finished and are ready to post"
|
|
active={tab === 'demography-automation'}
|
|
onSelect={onTab}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{projects.includes('tebra') && (
|
|
<>
|
|
<SectionHead project="tebra" first={projects[0] === 'tebra'} />
|
|
{isAdmin && (
|
|
<Item
|
|
tab="tebra-roi"
|
|
icon={IconSpend}
|
|
label="ROI Dashboard"
|
|
hint="What the Example Client pipeline has cost, and what that bought"
|
|
active={tab === 'tebra-roi'}
|
|
onSelect={onTab}
|
|
/>
|
|
)}
|
|
<Item
|
|
tab="tebra-extraction"
|
|
dropStage="extraction"
|
|
dropTab="tebra-extraction"
|
|
icon={IconPage}
|
|
label="Example Client Charge Extraction"
|
|
hint="Example Client tickets: extract and clear the flagged fields"
|
|
active={tab === 'tebra-extraction'}
|
|
n={counts.tebra.extraction}
|
|
onSelect={onTab}
|
|
onDropBatch={onDropBatch}
|
|
onDropRun={onDropRun}
|
|
/>
|
|
<Item
|
|
tab="tebra-automation"
|
|
dropStage="automation"
|
|
dropTab="tebra-automation"
|
|
icon={IconLoop}
|
|
label="Example Client Charge Automation"
|
|
hint="Example Client batches that finished extraction and are ready to post"
|
|
active={tab === 'tebra-automation'}
|
|
n={counts.tebra.automation}
|
|
onSelect={onTab}
|
|
onDropBatch={onDropBatch}
|
|
onDropRun={onDropRun}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* One People entry for the whole app, below every project section
|
|
rather than inside one — access is an account-level thing, not a
|
|
board, and a copy under each project only ever asked "which of these
|
|
two identical links did I mean". The screen itself is merged for
|
|
anyone who can see both projects (see UsersPanel); a single-project
|
|
admin lands on their own project's screen, which is why the tab id
|
|
follows `projects` rather than being fixed — 'people' maps to ECW,
|
|
and a Tebra-only admin must not be sent to a board they cannot see
|
|
(App.tsx bounces them straight back off it).
|
|
|
|
Not a drop target: this is settings. Admins need it to grant access;
|
|
operators have nothing to do here and the endpoint would refuse them
|
|
anyway. */}
|
|
{isAdmin && (
|
|
<>
|
|
<hr className="sb-rail-divider" />
|
|
<Item
|
|
tab={projects.includes('ecw') ? 'people' : 'tebra-people'}
|
|
icon={IconPatients}
|
|
label="User Access"
|
|
hint="Who can sign in, and what each of them may do"
|
|
active={tab === 'people' || tab === 'tebra-people'}
|
|
onSelect={onTab}
|
|
/>
|
|
</>
|
|
)}
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
/** One project's name above its own nav list, with the bar glyph the mockup
|
|
* uses for a group header. The divider is skipped on the first section —
|
|
* there is nothing yet to divide it from. */
|
|
function SectionHead({ project, first }: { project: Project; first: boolean }) {
|
|
return (
|
|
<>
|
|
{!first && <hr className="sb-rail-divider" />}
|
|
<div className="sb-rail-group">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
<path d="M5 20V11M12 20V5M19 20V14" />
|
|
</svg>
|
|
<span className="whitespace-pre-line">{PROJECT_LABEL[project]}</span>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const BATCH_DRAG_TYPE = 'application/x-superbill-batch'
|
|
const RUN_DRAG_TYPE = 'application/x-superbill-run'
|
|
|
|
function Item({
|
|
tab,
|
|
dropStage,
|
|
dropTab,
|
|
icon: IconEl,
|
|
label,
|
|
hint,
|
|
active,
|
|
n,
|
|
onSelect,
|
|
onDropBatch,
|
|
onDropRun,
|
|
}: {
|
|
tab: BoardTab
|
|
/** The stage a card dropped here should move to. Absent on sections that
|
|
* are a view rather than a place, which is what makes them reject a drop. */
|
|
dropStage?: BatchStage
|
|
/** The board to navigate to after a drop. Two projects can share a stage
|
|
* name (both have an "extraction" and an "automation"), so the stage alone
|
|
* no longer identifies which board a card landed on — this does. Defaults
|
|
* to `tab`, which was the drop target anyway on every item that has one. */
|
|
dropTab?: BoardTab
|
|
icon: (p: { size?: number; className?: string }) => ReactElement
|
|
label: string
|
|
hint: string
|
|
active: boolean
|
|
/** Omitted on sections that count nothing — a report has no queue depth. */
|
|
n?: number
|
|
onSelect: (t: BoardTab) => void
|
|
onDropBatch?: (batchId: string, stage: BatchStage) => void
|
|
onDropRun?: (jobId: string) => void
|
|
}) {
|
|
const [over, setOver] = useState(false)
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(tab)}
|
|
title={hint}
|
|
aria-current={active ? 'page' : undefined}
|
|
// A section is also a drop target, so a batch — or a single document —
|
|
// can be thrown at "Automation" without first switching to it and
|
|
// losing sight of the card being dragged.
|
|
onDragOver={(e) => {
|
|
if (!dropStage) return
|
|
const t = e.dataTransfer.types
|
|
if (t.includes(BATCH_DRAG_TYPE) || (onDropRun && t.includes(RUN_DRAG_TYPE))) {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
setOver(true)
|
|
}
|
|
}}
|
|
onDragLeave={() => setOver(false)}
|
|
onDrop={(e) => {
|
|
if (!dropStage) return
|
|
setOver(false)
|
|
const batchId = e.dataTransfer.getData(BATCH_DRAG_TYPE)
|
|
const runId = e.dataTransfer.getData(RUN_DRAG_TYPE)
|
|
if (!batchId && !runId) return
|
|
e.preventDefault()
|
|
if (batchId && onDropBatch) onDropBatch(batchId, dropStage)
|
|
else if (runId && onDropRun) onDropRun(runId)
|
|
// Navigate to the board that was dropped ON, not to the stage name —
|
|
// see the `dropTab` prop doc above.
|
|
onSelect(dropTab ?? tab)
|
|
}}
|
|
className={`sb-nav-item ${over ? 'bg-surface-2 text-ink-2' : ''}`}
|
|
>
|
|
<IconEl size={16} className="shrink-0" />
|
|
<span className="truncate">{label}</span>
|
|
{n !== undefined && (
|
|
// Red only when the number is work somebody has to pick up. A badge
|
|
// that is red at zero teaches people to stop reading it.
|
|
<span className={`sb-count ${n > 0 ? 'sb-count-hot' : ''}`}>{n}</span>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|