Initial MUH app implementation
This commit is contained in:
219
frontend/src/pages/AntibiogramPage.tsx
Normal file
219
frontend/src/pages/AntibiogramPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { apiGet, apiPut } from "../lib/api";
|
||||
import type {
|
||||
ActiveCatalogSummary,
|
||||
QuarterKey,
|
||||
SampleDetail,
|
||||
SensitivityResult,
|
||||
} from "../lib/types";
|
||||
|
||||
type GroupState = Record<string, SensitivityResult | undefined>;
|
||||
|
||||
function quarterIdentity(sample: SampleDetail, quarterKey: QuarterKey) {
|
||||
const quarter = sample.quarters.find((entry) => entry.quarterKey === quarterKey);
|
||||
if (!quarter) {
|
||||
return quarterKey;
|
||||
}
|
||||
return quarter.pathogenBusinessKey || quarter.customPathogenName || quarter.pathogenName || quarterKey;
|
||||
}
|
||||
|
||||
export default function AntibiogramPage() {
|
||||
const { sampleId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
|
||||
const [sample, setSample] = useState<SampleDetail | null>(null);
|
||||
const [groupState, setGroupState] = useState<Record<string, GroupState>>({});
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!sampleId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [catalogResponse, sampleResponse] = await Promise.all([
|
||||
apiGet<ActiveCatalogSummary>("/catalogs/summary"),
|
||||
apiGet<SampleDetail>(`/samples/${sampleId}`),
|
||||
]);
|
||||
setCatalogs(catalogResponse);
|
||||
setSample(sampleResponse);
|
||||
|
||||
const nextState: Record<string, GroupState> = {};
|
||||
sampleResponse.antibiogramTargets.forEach((referenceQuarter) => {
|
||||
const existingGroup =
|
||||
sampleResponse.antibiograms.find((entry) => entry.quarterKey === referenceQuarter) ??
|
||||
sampleResponse.antibiograms.find((entry) => entry.inheritedFromQuarter === referenceQuarter);
|
||||
nextState[referenceQuarter] = {};
|
||||
existingGroup?.entries.forEach((entry) => {
|
||||
nextState[referenceQuarter][entry.antibioticBusinessKey] = entry.result;
|
||||
});
|
||||
});
|
||||
setGroupState(nextState);
|
||||
} catch (loadError) {
|
||||
setMessage((loadError as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
}, [sampleId]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!sample) {
|
||||
return [];
|
||||
}
|
||||
return sample.antibiogramTargets.map((referenceQuarter) => {
|
||||
const identity = quarterIdentity(sample, referenceQuarter);
|
||||
const reference = sample.quarters.find((quarter) => quarter.quarterKey === referenceQuarter);
|
||||
const inherited = sample.quarters.filter(
|
||||
(quarter) =>
|
||||
quarter.quarterKey !== referenceQuarter &&
|
||||
quarterIdentity(sample, quarter.quarterKey) === identity &&
|
||||
quarter.requiresAntibiogram,
|
||||
);
|
||||
return { referenceQuarter, reference, inherited };
|
||||
});
|
||||
}, [sample]);
|
||||
|
||||
function updateResult(
|
||||
referenceQuarter: QuarterKey,
|
||||
antibioticBusinessKey: string,
|
||||
result: SensitivityResult,
|
||||
) {
|
||||
setGroupState((current) => ({
|
||||
...current,
|
||||
[referenceQuarter]: {
|
||||
...current[referenceQuarter],
|
||||
[antibioticBusinessKey]:
|
||||
current[referenceQuarter]?.[antibioticBusinessKey] === result ? undefined : result,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!sampleId || !sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/antibiogram`, {
|
||||
groups: groups.map((group) => ({
|
||||
referenceQuarter: group.referenceQuarter,
|
||||
entries: Object.entries(groupState[group.referenceQuarter] ?? {})
|
||||
.filter((entry): entry is [string, SensitivityResult] => Boolean(entry[1]))
|
||||
.map(([antibioticBusinessKey, result]) => ({
|
||||
antibioticBusinessKey,
|
||||
result,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
navigate(`/samples/${response.id}/${response.routeSegment}`);
|
||||
} catch (saveError) {
|
||||
setMessage((saveError as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sample || !catalogs) {
|
||||
return <div className="empty-state">Antibiogramm wird geladen ...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-card section-card--hero">
|
||||
<div>
|
||||
<p className="eyebrow">Antibiogramm</p>
|
||||
<h3>Probe {sample.sampleNumber}</h3>
|
||||
<p className="muted-text">
|
||||
Nur Viertel mit bakteriellem Wachstum werden angezeigt. Identische Erreger werden
|
||||
automatisch zusammengefasst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sample.antibiogramEditable ? null : (
|
||||
<div className="alert alert--warning">
|
||||
Das Antibiogramm ist in diesem Bearbeitungsstand nur noch lesbar.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||
</section>
|
||||
|
||||
{!groups.length ? (
|
||||
<div className="empty-state">Fuer diese Probe ist kein Antibiogramm erforderlich.</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<section key={group.referenceQuarter} className="section-card">
|
||||
<div className="section-card__header">
|
||||
<div>
|
||||
<p className="eyebrow">{group.reference?.label}</p>
|
||||
<h3>{group.reference?.customPathogenName || group.reference?.pathogenName || "Erreger"}</h3>
|
||||
</div>
|
||||
{group.inherited.length ? (
|
||||
<div className="info-chip">
|
||||
Gilt ebenfalls fuer {group.inherited.map((entry) => entry.label).join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Antibiotikum</th>
|
||||
<th>S</th>
|
||||
<th>I</th>
|
||||
<th>R</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{catalogs.antibiotics.map((antibiotic) => (
|
||||
<tr key={antibiotic.businessKey}>
|
||||
<td>
|
||||
<strong>{antibiotic.name}</strong>
|
||||
<small className="table-subtext">{antibiotic.code ?? "ANT"}</small>
|
||||
</td>
|
||||
{(["SENSITIVE", "INTERMEDIATE", "RESISTANT"] as SensitivityResult[]).map((result) => (
|
||||
<td key={result}>
|
||||
<button
|
||||
type="button"
|
||||
className={`matrix-button ${
|
||||
groupState[group.referenceQuarter]?.[antibiotic.businessKey] === result
|
||||
? "is-selected"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => updateResult(group.referenceQuarter, antibiotic.businessKey, result)}
|
||||
disabled={!sample.antibiogramEditable}
|
||||
>
|
||||
{result === "SENSITIVE" ? "S" : result === "INTERMEDIATE" ? "I" : "R"}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="page-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="accent-button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !sample.antibiogramEditable}
|
||||
>
|
||||
{saving ? "Speichern ..." : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user