Initial MUH app implementation

This commit is contained in:
2026-03-12 11:43:27 +01:00
commit fb8e3c8ef6
69 changed files with 8387 additions and 0 deletions

View File

@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiGet, apiPut } from "../lib/api";
import type { ActiveCatalogSummary, QuarterKey, QuarterView, SampleDetail } from "../lib/types";
type QuarterFormState = {
pathogenBusinessKey: string;
customPathogenName: string;
cellCount: string;
};
function quarterStateFromSample(sample: SampleDetail) {
return sample.quarters.reduce<Record<string, QuarterFormState>>((accumulator, quarter) => {
accumulator[quarter.quarterKey] = {
pathogenBusinessKey: quarter.pathogenBusinessKey ?? "",
customPathogenName: quarter.customPathogenName ?? "",
cellCount: quarter.cellCount ? String(quarter.cellCount) : "",
};
return accumulator;
}, {});
}
export default function AnamnesisPage() {
const { sampleId } = useParams();
const navigate = useNavigate();
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
const [sample, setSample] = useState<SampleDetail | null>(null);
const [quarterStates, setQuarterStates] = useState<Record<string, QuarterFormState>>({});
const [activeQuarter, setActiveQuarter] = useState<QuarterKey | null>(null);
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);
setQuarterStates(quarterStateFromSample(sampleResponse));
setActiveQuarter(sampleResponse.quarters[0]?.quarterKey ?? null);
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
void load();
}, [sampleId]);
const visibleQuarter = useMemo<QuarterView | null>(() => {
if (!sample) {
return null;
}
return sample.quarters.find((quarter) => quarter.quarterKey === activeQuarter) ?? sample.quarters[0] ?? null;
}, [activeQuarter, sample]);
function updateQuarter(quarterKey: QuarterKey, patch: Partial<QuarterFormState>) {
setQuarterStates((current) => ({
...current,
[quarterKey]: {
...current[quarterKey],
...patch,
},
}));
}
async function handleSave() {
if (!sampleId || !sample) {
return;
}
setSaving(true);
setMessage(null);
try {
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/anamnesis`, {
quarters: sample.quarters.map((quarter) => ({
quarterKey: quarter.quarterKey,
pathogenBusinessKey: quarterStates[quarter.quarterKey]?.pathogenBusinessKey || null,
customPathogenName: quarterStates[quarter.quarterKey]?.customPathogenName || null,
cellCount: quarterStates[quarter.quarterKey]?.cellCount
? Number(quarterStates[quarter.quarterKey]?.cellCount)
: null,
})),
});
navigate(`/samples/${response.id}/${response.routeSegment}`);
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
if (!sample || !catalogs || !visibleQuarter) {
return <div className="empty-state">Anamnese wird geladen ...</div>;
}
const state = quarterStates[visibleQuarter.quarterKey] ?? {
pathogenBusinessKey: "",
customPathogenName: "",
cellCount: "",
};
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Anamnese</p>
<h3>Probe {sample.sampleNumber}</h3>
<p className="muted-text">
Erreger koennen ueber Schnellwahl oder Freitext erfasst werden. Bei 4/4-Proben wird
jedes relevante Viertel separat dokumentiert.
</p>
</div>
{sample.anamnesisEditable ? null : (
<div className="alert alert--warning">
Die Anamnese ist in diesem Bearbeitungsstand nur noch lesbar.
</div>
)}
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
{sample.quarters.length > 1 ? (
<section className="section-card">
<div className="tab-row">
{sample.quarters.map((quarter) => (
<button
key={quarter.quarterKey}
type="button"
className={`tab-chip ${activeQuarter === quarter.quarterKey ? "is-active" : ""}`}
onClick={() => setActiveQuarter(quarter.quarterKey)}
>
{quarter.label}
{quarter.flagged ? " ⚠" : ""}
</button>
))}
</div>
</section>
) : null}
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Entnahmestelle</p>
<h3>{visibleQuarter.label}</h3>
{visibleQuarter.flagged ? (
<div className="info-chip">Auffaelliges Viertel markiert</div>
) : null}
<div className="pathogen-grid">
{catalogs.pathogens.map((pathogen) => (
<button
key={pathogen.businessKey}
type="button"
className={`pathogen-button ${
state.pathogenBusinessKey === pathogen.businessKey ? "is-selected" : ""
}`}
onClick={() =>
updateQuarter(visibleQuarter.quarterKey, {
pathogenBusinessKey: pathogen.businessKey,
customPathogenName: "",
})
}
disabled={!sample.anamnesisEditable}
>
<strong>{pathogen.name}</strong>
<small>{pathogen.code ?? pathogen.kind}</small>
</button>
))}
</div>
<label className="field">
<span>Erreger manuell eingeben</span>
<input
value={state.customPathogenName}
onChange={(event) =>
updateQuarter(visibleQuarter.quarterKey, {
customPathogenName: event.target.value,
pathogenBusinessKey: "",
})
}
disabled={!sample.anamnesisEditable}
/>
</label>
</article>
<article className="section-card">
<p className="eyebrow">Begleitdaten</p>
<label className="field">
<span>Zellzahl {sample.sampleKind === "DRY_OFF" ? "(optional)" : ""}</span>
<input
value={state.cellCount}
onChange={(event) => updateQuarter(visibleQuarter.quarterKey, { cellCount: event.target.value })}
disabled={!sample.anamnesisEditable}
inputMode="numeric"
/>
</label>
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Kein Wachstum oder verunreinigte Proben werden spaeter automatisch vom
Antibiogramm ausgeschlossen.
</p>
</div>
</article>
</section>
<div className="page-actions">
<button
type="button"
className="accent-button"
onClick={() => void handleSave()}
disabled={saving || !sample.anamnesisEditable}
>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</div>
);
}