const { useState, useEffect, useCallback } = React;
const API = "api";
const TYPES = [
{ key:"surgical", label:"Surgical" }, { key:"icd10", label:"ICD-10" },
{ key:"theatre_time", label:"Theatre time" }, { key:"anaesthetic", label:"Anaesthetic" },
{ key:"asa", label:"ASA" }, { key:"other", label:"Other" },
];
const STAGES = [
{ key:"st_detail", short:"Detail" }, { key:"st_ms", short:"MS" },
{ key:"st_cv", short:"CV" }, { key:"st_rp", short:"RP" },
];
// ---- code reference for the readable summary + quick-picks ----
const CODE_REF = {
"0039":{t:"anaesthetic",d:"General anaesthetic"},"0023":{t:"anaesthetic",d:"Sedation"},
"1218":{t:"surgical",d:"Appendicectomy"},"2814":{t:"surgical",d:"Lap cholecystectomy"},
"3092":{t:"surgical",d:"Knee arthroscopy"},"0147":{t:"theatre_time",d:"Theatre time"},
"543":{t:"asa",d:"ASA modifier"},"K35.8":{t:"icd10",d:"Acute appendicitis"},
"K80.2":{t:"icd10",d:"Gallstones"},"M23.2":{t:"icd10",d:"Meniscus tear"},
};
const lookup = raw => {
const code = raw.trim().toUpperCase();
const h = CODE_REF[code] || CODE_REF[raw.trim()];
return h ? { code, code_type:h.t, description:h.d } : { code, code_type:"surgical", description:null };
};
// ===========================================================================
// Case-number generator (runs entirely in the browser — no data leaves here)
// Structure: first 3 letters of SURNAME + 2-digit MONTH + first 3 of HOSPITAL
// e.g. Mabitsela, July, Milpark -> MAB07MIL
// ===========================================================================
function makeCaseNumber(surname, hospital, dateStr) {
const s = (surname||"").replace(/[^a-z]/gi,"").toUpperCase().slice(0,3).padEnd(3,"X");
const h = (hospital||"").replace(/[^a-z]/gi,"").toUpperCase().slice(0,3).padEnd(3,"X");
let mm = "00";
if (dateStr) { const d = new Date(dateStr); if(!isNaN(d)) mm = String(d.getMonth()+1).padStart(2,"0"); }
return s + mm + h;
}
async function api(path, opts={}) {
const res = await fetch(`${API}/${path}`, {
credentials:"same-origin",
headers: opts.body && !(opts.body instanceof FormData) ? {"Content-Type":"application/json"} : undefined,
...opts });
let d; try { d = await res.json(); } catch { d = {ok:false,error:"Bad response"}; }
if (!res.ok || d.ok===false) throw new Error(d.error||`Error ${res.status}`);
return d;
}
// ===========================================================================
function Login({ onIn }) {
const [pw,setPw]=useState(""); const [err,setErr]=useState(""); const [busy,setBusy]=useState(false);
const go=async()=>{ setBusy(true); setErr("");
try{ await api("auth.php",{method:"POST",body:JSON.stringify({action:"login",password:pw})}); onIn(); }
catch(e){ setErr(e.message); setBusy(false); } };
return (
B
Black Book
Anaesthetic case workflow
{err}
setPw(e.target.value)}
onKeyDown={e=>e.key==="Enter"&&go()} autoFocus />
);
}
function Dot({ s }) { return ; }
// Live client-side mirror of the server's recompute(), so tab dots react
// immediately as the user edits (server remains the source of truth on save).
function liveStages(c) {
const req=["hospital","medical_aid","procedure_type"];
const missing=req.some(r=>!c[r]); const hasCodes=(c.codes||[]).length>0;
let st_detail="grey";
if(hasCodes||c.hospital||c.medical_aid){ st_detail=(missing||!hasCodes)?"yellow":"green"; if(c.codes_more)st_detail="yellow"; }
const ms=c.ms_filed&&c.ms_scanned&&c.ms_uploaded&&c.ms_submitted;
const msAny=c.ms_filed||c.ms_scanned||c.ms_uploaded||c.ms_submitted;
const st_ms=ms?"green":(msAny?"yellow":"grey");
const surg=(c.codes||[]).filter(x=>x.code_type==="surgical"), anaes=(c.codes||[]).filter(x=>x.code_type==="anaesthetic");
const anyQ=(c.codes||[]).some(x=>x.code_status==="query"), anyV=(c.codes||[]).some(x=>x.verified);
let st_cv="grey";
if(anyV||anyQ){ const surgOk=surg.length?surg.some(x=>x.verified):true; const anOk=anaes.every(x=>x.verified);
st_cv=(surgOk&&anOk&&!anyQ)?"green":"yellow"; }
return { st_detail, st_ms, st_cv, st_rp:c.st_rp||"grey" };
}
function CaseCard({ c, onOpen }) {
const codes = c.codes||[];
const summary = [c.hospital, c.procedure_type].filter(Boolean).join(" · ");
return (
onOpen(c.id)}>
{c.case_number}
{c.theatre_date||"—"}
{summary||"No detail yet"}
{codes.length===0 && No codes}
{codes.map((cd,i)=>{cd.code})}
{STAGES.map(s=>(
{s.short}
))}
);
}
// ===========================================================================
// New-case private capture -> generates case number
// ===========================================================================
function PrivateCapture({ onDone, onClose }) {
const [firsts,setFirsts]=useState(""); const [surname,setSurname]=useState("");
const [hospital,setHospital]=useState(""); const [date,setDate]=useState(new Date().toISOString().slice(0,10));
const cn = (surname||hospital) ? makeCaseNumber(surname,hospital,date) : "";
return (
e.stopPropagation()}>
🔒 Private — stays on this device
New case
Used only to build a case number. The name is not saved or sent anywhere.
Case number
{cn ?
{cn}
:
— — —
}
Structure: first 3 of surname · month · first 3 of hospital.
You'll write this number next to the sticker in your book. Only this number is stored.
);
}
// ===========================================================================
// Case detail sheet with staged tabs
// ===========================================================================
function CaseSheet({ id, prefill, onClose, onSaved, toast }) {
const [c,setC]=useState(null);
const [saving,setSaving]=useState(false);
// amend: which section is being edited (null = read-only view). New cases start on Detail.
const [amend,setAmend]=useState(id ? null : "st_detail");
useEffect(()=>{
if (id) api(`cases.php?action=get&id=${id}`).then(r=>setC(normalise(r.case))).catch(e=>toast(e.message));
else setC(blank(prefill));
},[id]);
function normalise(x){ return { ...x,
codes:(x.codes||[]).map(cd=>({ ...cd, verified:!!+cd.verified,
code_status:cd.code_status||"none", comment:cd.comment||"" })),
ms_filed:!!+x.ms_filed, ms_scanned:!!+x.ms_scanned, ms_uploaded:!!+x.ms_uploaded, ms_submitted:!!+x.ms_submitted,
codes_more:!!+x.codes_more }; }
function blank(p){ return { id:null, case_number:p.case_number, theatre_date:p.theatre_date||"",
hospital:p.hospital||"", procedure_date:"", theatre_start:"", theatre_end:"", surgeon:"", medical_aid:"",
plan:"", procedure_type:"", codes_more:false, notes:"", codes:[],
ms_filed:false, ms_scanned:false, ms_uploaded:false, ms_submitted:false, ms_submitted_date:"",
st_detail:"grey", st_ms:"grey", st_cv:"grey", st_rp:"grey" }; }
if (!c) return ;
const set=(k,v)=>setC(o=>({...o,[k]:v}));
const setCode=(i,patch)=>setC(o=>({...o,codes:o.codes.map((x,j)=>j===i?{...x,...patch}:x)}));
const addCode=(cd={code:"",code_type:"surgical",description:""})=>setC(o=>({...o,codes:[...o.codes,cd]}));
const delCode=i=>setC(o=>({...o,codes:o.codes.filter((_,j)=>j!==i)}));
const merge=list=>setC(o=>{ const seen=new Set(o.codes.map(x=>x.code+"|"+x.code_type));
return {...o,codes:[...o.codes,...list.filter(x=>x.code&&!seen.has(x.code+"|"+x.code_type))]}; });
const REQUIRED=["hospital","medical_aid","procedure_type"];
const missing=k=>REQUIRED.includes(k)&&!c[k];
const save=async()=>{
setSaving(true);
const payload={ action:id?"update":"create", id:id||undefined,
case_number:c.case_number, theatre_date:c.theatre_date, procedure_date:c.procedure_date,
theatre_start:c.theatre_start, theatre_end:c.theatre_end, hospital:c.hospital, surgeon:c.surgeon,
medical_aid:c.medical_aid, plan:c.plan, procedure_type:c.procedure_type, codes_more:c.codes_more,
notes:c.notes, codes:c.codes, ms_filed:c.ms_filed, ms_scanned:c.ms_scanned, ms_uploaded:c.ms_uploaded,
ms_submitted:c.ms_submitted, ms_submitted_date:c.ms_submitted_date, st_rp:c.st_rp };
try{ await api("cases.php",{method:"POST",body:JSON.stringify(payload)});
toast(id?"Saved":"Case created"); onSaved(); }
catch(e){ toast(e.message); setSaving(false); }
};
const del=async()=>{ if(!confirm(`Delete ${c.case_number}?`))return;
try{ await api("cases.php",{method:"POST",body:JSON.stringify({action:"delete",id})}); toast("Deleted"); onSaved(); }
catch(e){ toast(e.message); } };
// amend: which section is being edited (null = read-only view). New cases start editing Detail.
const saveSection=async()=>{ await save(); setAmend(null); };
const ls=liveStages(c);
const fv=(v)=> (v===null||v===undefined||v==="") ? — : {v};
return (
e.stopPropagation()}>
{c.case_number}
{c.theatre_date||"no date"}
{/* ---------- DETAIL ---------- */}
{amend==="st_detail" ? (
) : (
Case detail
Hospital{fv(c.hospital)}
Surgeon{fv(c.surgeon)}
Medical aid{fv(c.medical_aid)}
Plan{fv(c.plan)}
Procedure{fv(c.procedure_type)}
Theatre time{(c.theatre_start||c.theatre_end)?{(c.theatre_start||"—")+" – "+(c.theatre_end||"—")}:—}
Codes {c.codes_more?"(more to add)":""}
{c.codes.length===0 && none}
{c.codes.map((cd,i)=>{cd.code})}
{c.notes &&
{c.notes}
}
)}
{/* ---------- MS ---------- */}
{amend==="st_ms" ? (
) : (
MedCore submission
{[["ms_filed","Filed"],["ms_scanned","Scanned"],["ms_uploaded","Uploaded"],["ms_submitted","Submitted"]].map(([k,l])=>(
{l}
{c[k]?"✓ done":"—"}
))}
{c.ms_submitted_date &&
Submitted date{c.ms_submitted_date}
}
)}
{/* ---------- CV ---------- */}
{amend==="st_cv" ? (
) : (
Code verification
{c.codes.length===0 &&
No codes to verify
}
{c.codes.map((cd,i)=>(
{cd.code} {TYPES.find(t=>t.key===cd.code_type)?.label}
{cd.code_status==="query"
? ⚑ query
: {cd.verified?"✓ verified":"—"}}
))}
{c.codes.some(x=>x.code_status==="query"&&x.comment) &&
c.codes.filter(x=>x.code_status==="query"&&x.comment).map((x,i)=>
⚑ {x.code}: {x.comment}
)}
)}
{/* ---------- RP ---------- */}
{amend==="st_rp" ? (
) : (
Remittance & payment
Status
{c.st_rp==="green"?"Done":c.st_rp==="yellow"?"Query":"Pending"}
)}
{id && !amend &&
}
);
}
function DetailTab({ c,set,setCode,addCode,delCode,merge,missing }) {
return (
<>
set("procedure_type",e.target.value)} placeholder="e.g. Appendicectomy" />
{c.codes.map((cd,i)=>(
{ const v=e.target.value; const look=lookup(v); setCode(i,{code:v, code_type:cd.code_type||look.code_type, description:cd.description||look.description}); }} />
))}
{["0039","0147","543","2814"].map(q=>)}
>
);
}
function MSTab({ c,set }) {
const ticks=[["ms_filed","Filed (physical)"],["ms_scanned","Scanned"],["ms_uploaded","Uploaded"],["ms_submitted","Submitted"]];
return (
<>
Tick each step as the case moves to the billing company. Green when all four are done.
{ticks.map(([k,l])=>(
set(k,!c[k])}>
{c[k]?"✓":""}
{l}
))}
{c.ms_submitted && (
set("ms_submitted_date",e.target.value)} />
)}
>
);
}
function CVTab({ c,setCode }) {
const surg=c.codes.filter(x=>x.code_type==="surgical");
const anaes=c.codes.filter(x=>x.code_type==="anaesthetic");
return (
<>
Tick each code that appears on GoodX. Surgical: any one is enough. Anaesthetic: all must be there. Flag a query on any code that's wrong.
{c.codes.length===0 && No codes yet
Add codes on the Detail tab first.
}
{c.codes.map((cd,idx)=>{
const i=c.codes.indexOf(cd);
return (
setCode(i,{verified:!cd.verified})}>{cd.verified?"✓":""}
{cd.code}
{TYPES.find(t=>t.key===cd.code_type)?.label}
{cd.code_status==="query" && (
setCode(i,{comment:e.target.value})} />
)}
);
})}
{surg.length>0 && Surgical verified: {surg.some(x=>x.verified)?"✓ yes":"— none yet"} · Anaesthetic all verified: {anaes.every(x=>x.verified)&&anaes.length>0?"✓ yes":"— not yet"}
}
>
);
}
function RPTab({ c,set }) {
return (
Remittance & payment
This stage is being designed with the payment scenarios (nothing / partial / paid to patient / paid). For now you can set its status manually.
{[["grey","Pending"],["yellow","Query"],["green","Done"]].map(([k,l])=>(
))}
);
}
// ===========================================================================
function Dashboard({ onLogout }) {
const [cases,setCases]=useState(null);
const [q,setQ]=useState(""); const [flagged,setFlagged]=useState(false);
const [sheet,setSheet]=useState(null); // null | {new:true,prefill} | {id}
const [toastMsg,setToastMsg]=useState("");
const toast=m=>{ setToastMsg(m); setTimeout(()=>setToastMsg(""),2100); };
const load=useCallback(async()=>{
const p=new URLSearchParams({action:"list"});
if(q.trim())p.set("q",q.trim()); if(flagged)p.set("flagged","1");
try{ const r=await api(`cases.php?${p}`); setCases(r.cases); }catch(e){ toast(e.message); setCases([]); }
},[q,flagged]);
useEffect(()=>{ load(); },[load]);
const seed=async()=>{ try{ await api("seed.php"); toast("Mock cases loaded"); load(); }catch(e){ toast(e.message); } };
return (
<>
Black Book
{cases?`${cases.length} case${cases.length!==1?"s":""}`:"…"}
Not started
Done
Query / attention
{cases===null &&
}
{cases && cases.length===0 && (
{q||flagged?"Nothing matches":"No cases yet"}
{q||flagged?"Clear the search or filter.":'Tap "Load mock data" or New case.'}
)}
{cases && cases.map(c=>
setSheet({id})} />)}
{sheet?.new && !sheet.prefill && (
setSheet(null)} onDone={p=>setSheet({new:true,prefill:p})} />
)}
{sheet?.new && sheet.prefill && (
setSheet(null)} onSaved={()=>{setSheet(null);load();}} toast={toast} />
)}
{sheet?.id && (
setSheet(null)} onSaved={()=>{setSheet(null);load();}} toast={toast} />
)}
{toastMsg && {toastMsg}
}
>
);
}
function App() {
const [authed,setAuthed]=useState(null);
useEffect(()=>{ api("auth.php",{method:"POST",body:JSON.stringify({action:"check"})})
.then(d=>setAuthed(!!d.authed)).catch(()=>setAuthed(false)); },[]);
const logout=async()=>{ try{ await api("auth.php",{method:"POST",body:JSON.stringify({action:"logout"})}); }catch{} setAuthed(false); };
if(authed===null) return ;
return authed ? : setAuthed(true)} />;
}
ReactDOM.createRoot(document.getElementById("root")).render();