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,326 @@
import { useEffect, useMemo, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import type { AdministrationOverview, MedicationCategory, PathogenKind } from "../lib/types";
type DatasetKey = "farmers" | "medications" | "pathogens" | "antibiotics";
type EditableRow = {
id: string;
businessKey: string;
name: string;
active: boolean;
updatedAt: string;
email?: string;
category?: MedicationCategory;
code?: string;
kind?: PathogenKind;
};
type DatasetsState = Record<DatasetKey, EditableRow[]>;
const DATASET_LABELS: Record<DatasetKey, string> = {
farmers: "Landwirte",
medications: "Medikamente",
pathogens: "Erreger",
antibiotics: "Antibiogramm",
};
function normalizeOverview(overview: AdministrationOverview): DatasetsState {
return {
farmers: overview.farmers.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
name: entry.name,
email: entry.email ?? "",
active: entry.active,
updatedAt: entry.updatedAt,
})),
medications: overview.medications.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
name: entry.name,
category: entry.category,
active: entry.active,
updatedAt: entry.updatedAt,
})),
pathogens: overview.pathogens.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
code: entry.code ?? "",
name: entry.name,
kind: entry.kind,
active: entry.active,
updatedAt: entry.updatedAt,
})),
antibiotics: overview.antibiotics.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
code: entry.code ?? "",
name: entry.name,
active: entry.active,
updatedAt: entry.updatedAt,
})),
};
}
function emptyRow(dataset: DatasetKey): EditableRow {
switch (dataset) {
case "farmers":
return { id: "", businessKey: "", name: "", email: "", active: true, updatedAt: new Date().toISOString() };
case "medications":
return {
id: "",
businessKey: "",
name: "",
category: "IN_UDDER",
active: true,
updatedAt: new Date().toISOString(),
};
case "pathogens":
return {
id: "",
businessKey: "",
code: "",
name: "",
kind: "BACTERIAL",
active: true,
updatedAt: new Date().toISOString(),
};
case "antibiotics":
return { id: "", businessKey: "", code: "", name: "", active: true, updatedAt: new Date().toISOString() };
}
}
export default function AdministrationPage() {
const [datasets, setDatasets] = useState<DatasetsState | null>(null);
const [selectedDataset, setSelectedDataset] = useState<DatasetKey>("farmers");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiGet<AdministrationOverview>("/admin");
setDatasets(normalizeOverview(response));
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
void load();
}, []);
const rows = useMemo(() => datasets?.[selectedDataset] ?? [], [datasets, selectedDataset]);
function updateRow(index: number, patch: Partial<EditableRow>) {
setDatasets((current) => {
if (!current) {
return current;
}
const nextRows = current[selectedDataset].map((row, rowIndex) =>
rowIndex === index ? { ...row, ...patch } : row,
);
return {
...current,
[selectedDataset]: nextRows,
};
});
}
function addRow() {
setDatasets((current) => {
if (!current) {
return current;
}
return {
...current,
[selectedDataset]: [...current[selectedDataset], emptyRow(selectedDataset)],
};
});
}
async function handleSave() {
if (!datasets) {
return;
}
setSaving(true);
setMessage(null);
try {
let response: EditableRow[];
switch (selectedDataset) {
case "farmers":
response = await apiPost<EditableRow[]>("/admin/farmers", rows.map((row) => ({
id: row.id || null,
name: row.name,
email: row.email || null,
active: row.active,
})));
break;
case "medications":
response = await apiPost<EditableRow[]>("/admin/medications", rows.map((row) => ({
id: row.id || null,
name: row.name,
category: row.category,
active: row.active,
})));
break;
case "pathogens":
response = await apiPost<EditableRow[]>("/admin/pathogens", rows.map((row) => ({
id: row.id || null,
code: row.code || null,
name: row.name,
kind: row.kind,
active: row.active,
})));
break;
case "antibiotics":
response = await apiPost<EditableRow[]>("/admin/antibiotics", rows.map((row) => ({
id: row.id || null,
code: row.code || null,
name: row.name,
active: row.active,
})));
break;
}
setDatasets((current) => (current ? { ...current, [selectedDataset]: response } : current));
setMessage("Aenderungen gespeichert.");
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Stammdaten direkt pflegen</h3>
<p className="muted-text">
Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte
Satz inaktiv sichtbar.
</p>
</div>
{message ? (
<div className={message.includes("gespeichert") ? "alert alert--success" : "alert alert--error"}>
{message}
</div>
) : null}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Datensatz</p>
<h3>{DATASET_LABELS[selectedDataset]}</h3>
</div>
<div className="choice-row">
{(Object.keys(DATASET_LABELS) as DatasetKey[]).map((dataset) => (
<button
key={dataset}
type="button"
className={`choice-chip ${selectedDataset === dataset ? "is-selected" : ""}`}
onClick={() => setSelectedDataset(dataset)}
>
{DATASET_LABELS[dataset]}
</button>
))}
</div>
</div>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
{selectedDataset === "farmers" ? <th>E-Mail</th> : null}
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
{selectedDataset === "pathogens" ? <th>Typ</th> : null}
<th>Aktiv</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.id || "new"}-${index}`}>
<td>
<input
value={row.name}
onChange={(event) => updateRow(index, { name: event.target.value })}
/>
</td>
{selectedDataset === "farmers" ? (
<td>
<input
value={row.email ?? ""}
onChange={(event) => updateRow(index, { email: event.target.value })}
/>
</td>
) : null}
{selectedDataset === "medications" ? (
<td>
<select
value={row.category}
onChange={(event) =>
updateRow(index, { category: event.target.value as MedicationCategory })
}
>
<option value="IN_UDDER">ins Euter</option>
<option value="SYSTEMIC_ANTIBIOTIC">systemisch Antibiotika</option>
<option value="SYSTEMIC_PAIN">systemisch Schmerzmittel</option>
<option value="DRY_SEALER">Versiegler</option>
<option value="DRY_ANTIBIOTIC">TS Antibiotika</option>
</select>
</td>
) : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? (
<td>
<input
value={row.code ?? ""}
onChange={(event) => updateRow(index, { code: event.target.value })}
/>
</td>
) : null}
{selectedDataset === "pathogens" ? (
<td>
<select
value={row.kind}
onChange={(event) => updateRow(index, { kind: event.target.value as PathogenKind })}
>
<option value="BACTERIAL">bakteriell</option>
<option value="NO_GROWTH">kein Wachstum</option>
<option value="CONTAMINATED">verunreinigt</option>
<option value="OTHER">sonstiges</option>
</select>
</td>
) : null}
<td>
<button
type="button"
className={`eye-button ${row.active ? "is-active" : "is-inactive"}`}
onClick={() => updateRow(index, { active: !row.active })}
>
{row.active ? "sichtbar" : "inaktiv"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="page-actions page-actions--space-between">
<button type="button" className="secondary-button" onClick={addRow}>
Anlegen
</button>
<button type="button" className="accent-button" onClick={() => void handleSave()} disabled={saving}>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</section>
</div>
);
}

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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,179 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { apiGet } from "../lib/api";
import type { DashboardOverview, LookupResult } from "../lib/types";
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function routeForSample(sampleId: string, routeSegment: string) {
return `/samples/${sampleId}/${routeSegment}`;
}
const STEP_LABELS: Record<string, string> = {
ANAMNESIS: "Anamnese",
ANTIBIOGRAM: "Antibiogramm",
THERAPY: "Therapie",
COMPLETED: "Abgeschlossen",
};
export default function HomePage() {
const navigate = useNavigate();
const [dashboard, setDashboard] = useState<DashboardOverview | null>(null);
const [sampleNumber, setSampleNumber] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadDashboard() {
try {
const response = await apiGet<DashboardOverview>("/dashboard");
setDashboard(response);
} finally {
setLoading(false);
}
}
void loadDashboard();
}, []);
async function handleLookup(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!sampleNumber.trim()) {
setMessage("Bitte eine Probennummer eingeben.");
return;
}
try {
const response = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber.trim()}`);
if (!response.found || !response.sampleId || !response.routeSegment) {
setMessage(response.message);
return;
}
setMessage(null);
navigate(routeForSample(response.sampleId, response.routeSegment));
} catch (lookupError) {
setMessage((lookupError as Error).message);
}
}
return (
<div className="page-stack">
<section className="hero-card">
<div>
<p className="eyebrow">Startseite</p>
<h3>Bearbeitungsstand sofort finden</h3>
<p className="muted-text">
Eine bekannte Probennummer oeffnet direkt den passenden Arbeitsschritt.
</p>
</div>
<form className="hero-card__form" onSubmit={handleLookup}>
<label className="field">
<span>Nummer</span>
<input
value={sampleNumber}
onChange={(event) => setSampleNumber(event.target.value)}
placeholder="z. B. 100203"
inputMode="numeric"
/>
</label>
<button type="submit" className="accent-button">
Probe oeffnen
</button>
<button type="button" className="secondary-button" onClick={() => navigate("/samples/new")}>
Neuanlage einer Probe
</button>
</form>
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="metrics-grid">
<article className="metric-card">
<span className="metric-card__label">Naechste Nummer</span>
<strong>{dashboard?.nextSampleNumber ?? "..."}</strong>
</article>
<article className="metric-card">
<span className="metric-card__label">Offene Proben</span>
<strong>{dashboard?.openSamples ?? "..."}</strong>
</article>
<article className="metric-card">
<span className="metric-card__label">Heute abgeschlossen</span>
<strong>{dashboard?.completedToday ?? "..."}</strong>
</article>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Arbeitsvorrat</p>
<h3>Zuletzt bearbeitete Proben</h3>
</div>
</div>
{loading ? (
<div className="empty-state">Dashboard wird geladen ...</div>
) : dashboard?.recentSamples.length ? (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Probe</th>
<th>Landwirt</th>
<th>Kuh</th>
<th>Typ</th>
<th>Status</th>
<th>Aktualisiert</th>
<th />
</tr>
</thead>
<tbody>
{dashboard.recentSamples.map((sample) => (
<tr key={sample.id}>
<td>{sample.sampleNumber}</td>
<td>{sample.farmerName}</td>
<td>{sample.cowLabel}</td>
<td>{sample.sampleKind === "DRY_OFF" ? "Trockensteller" : "Laktation"}</td>
<td>
<span className={`status-pill status-pill--${sample.currentStep.toLowerCase()}`}>
{STEP_LABELS[sample.currentStep]}
</span>
</td>
<td>{formatDate(sample.updatedAt)}</td>
<td>
<button
type="button"
className="table-link"
onClick={() =>
navigate(
routeForSample(
sample.id,
sample.currentStep === "ANTIBIOGRAM"
? "antibiogram"
: sample.currentStep === "THERAPY" || sample.currentStep === "COMPLETED"
? "therapy"
: "anamnesis",
),
)
}
>
Oeffnen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty-state">Noch keine Proben vorhanden.</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,255 @@
import { FormEvent, useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
type FeedbackState =
| { type: "error"; text: string }
| { type: "success"; text: string }
| null;
export default function LoginPage() {
const [users, setUsers] = useState<UserOption[]>([]);
const [manualCode, setManualCode] = useState("");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [registration, setRegistration] = useState({
companyName: "",
address: "",
email: "",
password: "",
});
const [loading, setLoading] = useState(true);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setUser } = useSession();
async function loadUsers() {
setLoading(true);
setFeedback(null);
try {
const response = await apiGet<UserOption[]>("/session/users");
setUsers(response);
} catch (loadError) {
setFeedback({ type: "error", text: (loadError as Error).message });
setUsers([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadUsers();
}, []);
async function handleCodeLogin(code: string) {
if (!code.trim()) {
setFeedback({ type: "error", text: "Bitte ein Benutzerkuerzel eingeben oder auswaehlen." });
return;
}
try {
const response = await apiPost<UserOption>("/session/login", { code });
setUser(response);
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
}
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
const response = await apiPost<UserOption>("/session/password-login", {
identifier,
password,
});
setUser(response);
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
}
async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
const response = await apiPost<UserOption>("/session/register", registration);
setFeedback({
type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`,
});
setUser(response);
} catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message });
}
}
return (
<div className="login-page">
<section className="login-hero">
<div className="login-hero__copy">
<p className="eyebrow">MUH-App</p>
<h1>Moderne Steuerung fuer Milchproben und Therapien.</h1>
<p className="hero-text">
Fokus auf klare Arbeitsablaeufe, schnelle Probenbearbeitung und ein Portal
fuer Verwaltung, Berichtsdruck und Versandstatus.
</p>
</div>
<div className="login-hero__panel">
<div className="panel-glow" />
<p className="eyebrow">Zugang</p>
<h2>Anmelden oder registrieren</h2>
<p className="muted-text">
Weiterhin moeglich: Direktanmeldung per Benutzerkuerzel. Neu: Login mit
E-Mail/Benutzername und Passwort.
</p>
{feedback ? (
<div className={`alert ${feedback.type === "success" ? "alert--success" : "alert--error"}`}>
{feedback.text}
</div>
) : null}
<div className="login-panel__section">
<div className="section-card__header">
<div>
<p className="eyebrow">Schnelllogin</p>
<h3>Benutzerkuerzel</h3>
</div>
<button type="button" className="secondary-button" onClick={() => void loadUsers()}>
Neu laden
</button>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen ...</div>
) : users.length ? (
<div className="user-grid">
{users.map((user) => (
<button
key={user.id}
type="button"
className="user-card"
onClick={() => void handleCodeLogin(user.code)}
>
<span className="user-card__code">{user.code}</span>
<strong>{user.displayName}</strong>
<small>
{user.role === "ADMIN"
? "Admin"
: user.role === "CUSTOMER"
? "Kunde"
: "App"}
</small>
</button>
))}
</div>
) : (
<div className="page-stack">
<div className="empty-state">
Es wurden keine aktiven Benutzer geladen. Das Kuersel kann trotzdem direkt
eingegeben werden.
</div>
<label className="field">
<span>Benutzerkuerzel</span>
<input
value={manualCode}
onChange={(event) => setManualCode(event.target.value.toUpperCase())}
placeholder="z. B. SV"
/>
</label>
<div className="page-actions">
<button
type="button"
className="accent-button"
onClick={() => void handleCodeLogin(manualCode)}
>
Mit Kuerzel anmelden
</button>
</div>
</div>
)}
</div>
<div className="divider-label">oder mit Passwort</div>
<div className="auth-grid">
<form className="login-panel__section" onSubmit={handlePasswordLogin}>
<p className="eyebrow">Login</p>
<h3>E-Mail oder Benutzername</h3>
<label className="field">
<span>E-Mail / Benutzername</span>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder="z. B. admin oder name@hof.de"
/>
</label>
<label className="field">
<span>Passwort</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Mit Passwort anmelden
</button>
</div>
</form>
<form className="login-panel__section" onSubmit={handleRegister}>
<p className="eyebrow">Kundenregistrierung</p>
<h3>Neues Kundenkonto anlegen</h3>
<label className="field">
<span>Firmenname</span>
<input
value={registration.companyName}
onChange={(event) =>
setRegistration((current) => ({ ...current, companyName: event.target.value }))
}
placeholder="z. B. Muster Agrar GmbH"
/>
</label>
<label className="field">
<span>Adresse</span>
<textarea
value={registration.address}
onChange={(event) =>
setRegistration((current) => ({ ...current, address: event.target.value }))
}
placeholder="Strasse, Hausnummer, PLZ Ort"
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={registration.email}
onChange={(event) =>
setRegistration((current) => ({ ...current, email: event.target.value }))
}
/>
</label>
<label className="field">
<span>Passwort</span>
<input
type="password"
value={registration.password}
onChange={(event) =>
setRegistration((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Registrieren
</button>
</div>
</form>
</div>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,429 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api";
import type { PortalSnapshot, UserRole } from "../lib/types";
function formatDate(value: string | null) {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export default function PortalPage() {
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null);
const [selectedFarmer, setSelectedFarmer] = useState("");
const [farmerQuery, setFarmerQuery] = useState("");
const [cowQuery, setCowQuery] = useState("");
const [sampleNumberQuery, setSampleNumberQuery] = useState("");
const [dateQuery, setDateQuery] = useState("");
const [selectedReports, setSelectedReports] = useState<string[]>([]);
const [message, setMessage] = useState<string | null>(null);
const [userForm, setUserForm] = useState({
code: "",
displayName: "",
email: "",
portalLogin: "",
password: "",
role: "APP" as UserRole,
});
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
async function loadSnapshot() {
const params = new URLSearchParams();
if (selectedFarmer) {
params.set("farmerBusinessKey", selectedFarmer);
}
if (farmerQuery) {
params.set("farmerQuery", farmerQuery);
}
if (cowQuery) {
params.set("cowQuery", cowQuery);
}
if (sampleNumberQuery) {
params.set("sampleNumber", sampleNumberQuery);
}
if (dateQuery) {
params.set("date", dateQuery);
}
const response = await apiGet<PortalSnapshot>(`/portal/snapshot?${params.toString()}`);
setSnapshot(response);
setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId));
}
useEffect(() => {
void loadSnapshot();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reportCount = useMemo(() => selectedReports.length, [selectedReports]);
function toggleReport(sampleId: string) {
setSelectedReports((current) =>
current.includes(sampleId)
? current.filter((entry) => entry !== sampleId)
: [...current, sampleId],
);
}
async function handleSearch(event?: FormEvent) {
event?.preventDefault();
try {
setMessage(null);
await loadSnapshot();
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
async function handleDispatchReports() {
try {
const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", {
sampleIds: selectedReports,
});
setMessage(
response.mailDeliveryActive
? "Berichte wurden versendet."
: "Berichte wurden als versendet markiert. Fuer echten Mailversand fehlt noch SMTP-Konfiguration.",
);
await loadSnapshot();
} catch (dispatchError) {
setMessage((dispatchError as Error).message);
}
}
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
await apiPost("/portal/users", {
...userForm,
active: true,
});
setUserForm({
code: "",
displayName: "",
email: "",
portalLogin: "",
password: "",
role: "APP",
});
setMessage("Benutzer gespeichert.");
await loadSnapshot();
} catch (userError) {
setMessage((userError as Error).message);
}
}
async function handleDeleteUser(userId: string) {
try {
await apiDelete(`/portal/users/${userId}`);
setMessage("Benutzer geloescht.");
await loadSnapshot();
} catch (deleteError) {
setMessage((deleteError as Error).message);
}
}
async function handlePasswordChange(userId: string) {
try {
await apiPost(`/portal/users/${userId}/password`, {
password: passwordDrafts[userId],
});
setPasswordDrafts((current) => ({ ...current, [userId]: "" }));
setMessage("Passwort aktualisiert.");
} catch (passwordError) {
setMessage((passwordError as Error).message);
}
}
async function handleToggleBlocked(sampleId: string, blocked: boolean) {
try {
await apiPatch(`/portal/reports/${sampleId}/block`, { blocked });
await loadSnapshot();
} catch (blockError) {
setMessage((blockError as Error).message);
}
}
if (!snapshot) {
return <div className="empty-state">Portal wird geladen ...</div>;
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">MUH-Portal</p>
<h3>Benutzer, Berichtversand und Schnellsuche</h3>
<p className="muted-text">
Das Portal kombiniert Verwaltungsfunktionen mit dem Versandstatus aller
abgeschlossenen Proben.
</p>
</div>
{message ? (
<div className={message.includes("gespeichert") || message.includes("versendet") || message.includes("aktualisiert") || message.includes("geloescht") ? "alert alert--success" : "alert alert--error"}>
{message}
</div>
) : null}
</section>
<section className="portal-grid">
<article className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Bericht-Versand</p>
<h3>Versandbereite Proben</h3>
</div>
<div className="info-chip">{reportCount} markiert</div>
</div>
<div className="check-list">
{snapshot.reportCandidates.map((candidate) => (
<label key={candidate.sampleId} className="check-list__item">
<input
type="checkbox"
checked={selectedReports.includes(candidate.sampleId)}
onChange={() => toggleReport(candidate.sampleId)}
/>
<span>
Probe {candidate.sampleNumber} | {candidate.farmerName} | {candidate.farmerEmail}
</span>
</label>
))}
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void handleDispatchReports()}>
Markierte Mails versenden
</button>
</div>
</article>
<article className="section-card">
<p className="eyebrow">Benutzerverwaltung</p>
<form className="field-grid" onSubmit={handleCreateUser}>
<label className="field">
<span>Kuerzel</span>
<input
value={userForm.code}
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
/>
</label>
<label className="field">
<span>Name</span>
<input
value={userForm.displayName}
onChange={(event) =>
setUserForm((current) => ({ ...current, displayName: event.target.value }))
}
/>
</label>
<label className="field">
<span>Login</span>
<input
value={userForm.portalLogin}
onChange={(event) =>
setUserForm((current) => ({ ...current, portalLogin: event.target.value }))
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<label className="field">
<span>Passwort</span>
<input
value={userForm.password}
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
type="password"
/>
</label>
<label className="field">
<span>Rolle</span>
<select
value={userForm.role}
onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))}
>
<option value="APP">APP</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div>
</form>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Kuerzel</th>
<th>Name</th>
<th>E-Mail</th>
<th>Login</th>
<th>Rolle</th>
<th>Passwort</th>
<th />
</tr>
</thead>
<tbody>
{snapshot.users.map((user) => (
<tr key={user.id}>
<td>{user.code}</td>
<td>{user.displayName}</td>
<td>{user.email ?? "-"}</td>
<td>{user.portalLogin ?? "-"}</td>
<td>{user.role}</td>
<td>
<input
type="password"
value={passwordDrafts[user.id] ?? ""}
onChange={(event) =>
setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
}
placeholder="Neues Passwort"
/>
</td>
<td className="table-actions">
<button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
Speichern
</button>
<button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
</section>
<section className="section-card">
<form className="field-grid" onSubmit={handleSearch}>
<label className="field">
<span>Landwirt suchen</span>
<input value={farmerQuery} onChange={(event) => setFarmerQuery(event.target.value)} />
</label>
<label className="field">
<span>Gefundener Landwirt</span>
<select value={selectedFarmer} onChange={(event) => setSelectedFarmer(event.target.value)}>
<option value="">alle / noch keiner</option>
{snapshot.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name}
</option>
))}
</select>
</label>
<label className="field">
<span>Kuh</span>
<input value={cowQuery} onChange={(event) => setCowQuery(event.target.value)} />
</label>
<label className="field">
<span>Probe-Nr.</span>
<input value={sampleNumberQuery} onChange={(event) => setSampleNumberQuery(event.target.value)} />
</label>
<label className="field">
<span>Datum</span>
<input type="date" value={dateQuery} onChange={(event) => setDateQuery(event.target.value)} />
</label>
<div className="page-actions page-actions--align-end">
<button type="submit" className="accent-button">
Suche starten
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
setSelectedFarmer("");
setFarmerQuery("");
setCowQuery("");
setSampleNumberQuery("");
setDateQuery("");
void handleSearch();
}}
>
Zuruecksetzen
</button>
</div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Suchergebnis</p>
<h3>Gefundene Milchproben</h3>
</div>
</div>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Probe</th>
<th>Anlage</th>
<th>Landwirt</th>
<th>Kuh</th>
<th>Typ</th>
<th>Interne Bemerkung</th>
<th>PDF</th>
<th>Versand</th>
</tr>
</thead>
<tbody>
{snapshot.samples.map((sample) => (
<tr key={sample.sampleId}>
<td>{sample.sampleNumber}</td>
<td>{formatDate(sample.createdAt)}</td>
<td>{sample.farmerName}</td>
<td>{sample.cowNumber}{sample.cowName ? ` / ${sample.cowName}` : ""}</td>
<td>{sample.sampleKindLabel === "DRY_OFF" ? "Trockensteller" : "Milchprobe"}</td>
<td>{sample.internalNote ?? "-"}</td>
<td>
{sample.completed ? (
<a className="table-link" href={pdfUrl(sample.sampleId)} target="_blank" rel="noreferrer">
PDF
</a>
) : (
<span className="muted-text">-</span>
)}
</td>
<td>
<div className="table-actions">
<span className={`status-pill ${sample.reportSent ? "status-pill--completed" : "status-pill--therapy"}`}>
{sample.reportSent ? "versendet" : "offen"}
</span>
{sample.completed ? (
<button
type="button"
className="table-link"
onClick={() => void handleToggleBlocked(sample.sampleId, !sample.reportBlocked)}
>
{sample.reportBlocked ? "freigeben" : "blockieren"}
</button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiGet, apiPost, apiPut } from "../lib/api";
import { useSession } from "../lib/session";
import type {
ActiveCatalogSummary,
DashboardOverview,
QuarterKey,
SampleDetail,
SampleKind,
SamplingMode,
} from "../lib/types";
const QUARTERS: { key: QuarterKey; label: string }[] = [
{ key: "LEFT_FRONT", label: "Vorne links" },
{ key: "RIGHT_FRONT", label: "Vorne rechts" },
{ key: "LEFT_REAR", label: "Hinten links" },
{ key: "RIGHT_REAR", label: "Hinten rechts" },
];
export default function SampleRegistrationPage() {
const { sampleId } = useParams();
const navigate = useNavigate();
const { user } = useSession();
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
const [sampleNumber, setSampleNumber] = useState<number | null>(null);
const [editable, setEditable] = useState(true);
const [farmerBusinessKey, setFarmerBusinessKey] = useState("");
const [cowNumber, setCowNumber] = useState("");
const [cowName, setCowName] = useState("");
const [sampleKind, setSampleKind] = useState<SampleKind>("LACTATION");
const [samplingMode, setSamplingMode] = useState<SamplingMode>("SINGLE_SITE");
const [flaggedQuarters, setFlaggedQuarters] = useState<QuarterKey[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const catalogResponse = await apiGet<ActiveCatalogSummary>("/catalogs/summary");
setCatalogs(catalogResponse);
if (sampleId) {
const sample = await apiGet<SampleDetail>(`/samples/${sampleId}`);
setSampleNumber(sample.sampleNumber);
setEditable(sample.registrationEditable);
setFarmerBusinessKey(sample.farmerBusinessKey);
setCowNumber(sample.cowNumber);
setCowName(sample.cowName ?? "");
setSampleKind(sample.sampleKind);
setSamplingMode(sample.samplingMode);
setFlaggedQuarters(
sample.quarters.filter((quarter) => quarter.flagged).map((quarter) => quarter.quarterKey),
);
} else {
const dashboard = await apiGet<DashboardOverview>("/dashboard");
setSampleNumber(dashboard.nextSampleNumber);
setFarmerBusinessKey(catalogResponse.farmers[0]?.businessKey ?? "");
}
} catch (loadError) {
setMessage((loadError as Error).message);
} finally {
setLoading(false);
}
}
void load();
}, [sampleId]);
function toggleFlaggedQuarter(quarterKey: QuarterKey) {
setFlaggedQuarters((current) =>
current.includes(quarterKey)
? current.filter((entry) => entry !== quarterKey)
: [...current, quarterKey],
);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!user) {
return;
}
if (!farmerBusinessKey || !cowNumber.trim()) {
setMessage("Landwirt und Kuh-Nummer sind erforderlich.");
return;
}
setSaving(true);
setMessage(null);
const payload = {
farmerBusinessKey,
cowNumber,
cowName,
sampleKind,
samplingMode,
flaggedQuarters,
userCode: user.code,
userDisplayName: user.displayName,
};
try {
const response = sampleId
? await apiPut<SampleDetail>(`/samples/${sampleId}/registration`, payload)
: await apiPost<SampleDetail>("/samples", payload);
navigate(`/samples/${response.id}/${response.routeSegment}`);
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
if (loading) {
return <div className="empty-state">Probe wird vorbereitet ...</div>;
}
return (
<form className="page-stack" onSubmit={handleSubmit}>
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Neuanlage</p>
<h3>Probe {sampleNumber ?? "..."}</h3>
<p className="muted-text">
Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich ueber den
Schalter TS markieren.
</p>
</div>
{!editable ? (
<div className="alert alert--warning">
Diese Probe ist bereits weiter im Ablauf. Stammdaten sind nicht mehr editierbar.
</div>
) : null}
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Stammdaten</p>
<div className="field-grid">
<label className="field">
<span>Landwirt</span>
<select
value={farmerBusinessKey}
onChange={(event) => setFarmerBusinessKey(event.target.value)}
disabled={!editable}
>
{catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name}
</option>
))}
</select>
</label>
<label className="field">
<span>Kuh-Nummer</span>
<input
value={cowNumber}
onChange={(event) => setCowNumber(event.target.value)}
disabled={!editable}
/>
</label>
<label className="field">
<span>Kuh-Name</span>
<input
value={cowName}
onChange={(event) => setCowName(event.target.value)}
disabled={!editable}
/>
</label>
</div>
</article>
<article className="section-card">
<p className="eyebrow">Probentyp</p>
<div className="choice-row">
<button
type="button"
className={`choice-chip ${sampleKind === "LACTATION" ? "is-selected" : ""}`}
onClick={() => setSampleKind("LACTATION")}
disabled={!editable}
>
Laktationsprobe
</button>
<button
type="button"
className={`choice-chip ${sampleKind === "DRY_OFF" ? "is-selected" : ""}`}
onClick={() => setSampleKind("DRY_OFF")}
disabled={!editable}
>
TS
</button>
</div>
<p className="eyebrow section-card__spacer">Entnahmestelle</p>
<div className="choice-row">
<button
type="button"
className={`choice-chip ${samplingMode === "SINGLE_SITE" ? "is-selected" : ""}`}
onClick={() => setSamplingMode("SINGLE_SITE")}
disabled={!editable}
>
Einzelprobe
</button>
<button
type="button"
className={`choice-chip ${samplingMode === "FOUR_QUARTER" ? "is-selected" : ""}`}
onClick={() => setSamplingMode("FOUR_QUARTER")}
disabled={!editable}
>
4/4 Probe
</button>
<button
type="button"
className={`choice-chip ${samplingMode === "UNKNOWN_SITE" ? "is-selected" : ""}`}
onClick={() => setSamplingMode("UNKNOWN_SITE")}
disabled={!editable}
>
Unbek.
</button>
</div>
</article>
</section>
{samplingMode === "FOUR_QUARTER" ? (
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Auffaellige Viertel</p>
<h3>Viertel markieren</h3>
</div>
</div>
<div className="quarter-grid">
{QUARTERS.map((quarter) => (
<button
key={quarter.key}
type="button"
className={`quarter-tile ${flaggedQuarters.includes(quarter.key) ? "is-flagged" : ""}`}
onClick={() => toggleFlaggedQuarter(quarter.key)}
disabled={!editable}
>
<span>{quarter.label}</span>
<strong>{flaggedQuarters.includes(quarter.key) ? "⚠" : "OK"}</strong>
</button>
))}
</div>
</section>
) : null}
<div className="page-actions">
<button type="submit" className="accent-button" disabled={saving || !editable}>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,284 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { apiGet, apiPut } from "../lib/api";
import type {
ActiveCatalogSummary,
MedicationCategory,
SampleDetail,
} from "../lib/types";
function medicationOptions(catalogs: ActiveCatalogSummary, category: MedicationCategory) {
return catalogs.medications.filter((medication) => medication.category === category);
}
export default function TherapyPage() {
const { sampleId } = useParams();
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
const [sample, setSample] = useState<SampleDetail | null>(null);
const [continueStarted, setContinueStarted] = useState(false);
const [switchTherapy, setSwitchTherapy] = useState(false);
const [inUdderMedicationKeys, setInUdderMedicationKeys] = useState<string[]>([]);
const [inUdderOther, setInUdderOther] = useState("");
const [systemicMedicationKeys, setSystemicMedicationKeys] = useState<string[]>([]);
const [systemicOther, setSystemicOther] = useState("");
const [drySealerKeys, setDrySealerKeys] = useState<string[]>([]);
const [dryAntibioticKeys, setDryAntibioticKeys] = useState<string[]>([]);
const [farmerNote, setFarmerNote] = useState("");
const [internalNote, setInternalNote] = useState("");
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);
setContinueStarted(sampleResponse.therapy?.continueStarted ?? false);
setSwitchTherapy(sampleResponse.therapy?.switchTherapy ?? false);
setInUdderMedicationKeys(sampleResponse.therapy?.inUdderMedicationKeys ?? []);
setInUdderOther(sampleResponse.therapy?.inUdderOther ?? "");
setSystemicMedicationKeys(sampleResponse.therapy?.systemicMedicationKeys ?? []);
setSystemicOther(sampleResponse.therapy?.systemicOther ?? "");
setDrySealerKeys(sampleResponse.therapy?.drySealerKeys ?? []);
setDryAntibioticKeys(sampleResponse.therapy?.dryAntibioticKeys ?? []);
setFarmerNote(sampleResponse.therapy?.farmerNote ?? "");
setInternalNote(sampleResponse.therapy?.internalNote ?? "");
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
void load();
}, [sampleId]);
const therapyLocked = useMemo(() => sample?.completed ?? false, [sample]);
function toggleSelection(list: string[], value: string, setter: (next: string[]) => void) {
setter(list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]);
}
async function handleSave() {
if (!sampleId) {
return;
}
setSaving(true);
setMessage(null);
try {
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/therapy`, {
continueStarted,
switchTherapy,
inUdderMedicationKeys,
inUdderOther,
systemicMedicationKeys,
systemicOther,
drySealerKeys,
dryAntibioticKeys,
farmerNote,
internalNote,
});
setSample(response);
setMessage(response.completed ? "Probe gespeichert und abgeschlossen." : "Aenderung gespeichert.");
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
if (!sample || !catalogs) {
return <div className="empty-state">Therapieempfehlung wird geladen ...</div>;
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Therapieempfehlung</p>
<h3>Probe {sample.sampleNumber}</h3>
<p className="muted-text">
Laktations- und Trockenstellerproben verwenden unterschiedliche Medikationsgruppen.
Bei abgeschlossenen Proben bleibt nur die interne Bemerkung editierbar.
</p>
</div>
{sample.completed ? (
<div className="alert alert--warning">
Probe abgeschlossen. Nur das Feld Interne Bemerkung kann noch angepasst werden.
</div>
) : null}
{message ? <div className={message.includes("gespeichert") ? "alert alert--success" : "alert alert--error"}>{message}</div> : null}
</section>
{sample.sampleKind === "LACTATION" ? (
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Empfehlung / Therapie</p>
<div className="choice-row">
<button
type="button"
className={`choice-chip ${continueStarted ? "is-selected" : ""}`}
onClick={() => {
setContinueStarted((current) => !current);
if (!continueStarted) {
setSwitchTherapy(false);
}
}}
disabled={therapyLocked}
>
weiter wie begonnen
</button>
<button
type="button"
className={`choice-chip ${switchTherapy ? "is-selected" : ""}`}
onClick={() => {
setSwitchTherapy((current) => !current);
if (!switchTherapy) {
setContinueStarted(false);
}
}}
disabled={therapyLocked}
>
umstellen
</button>
</div>
<p className="eyebrow section-card__spacer">ins Euter</p>
<div className="choice-row choice-row--wrap">
{medicationOptions(catalogs, "IN_UDDER").map((medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${inUdderMedicationKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() =>
toggleSelection(inUdderMedicationKeys, medication.businessKey, setInUdderMedicationKeys)
}
disabled={therapyLocked}
>
{medication.name}
</button>
))}
</div>
<label className="field">
<span>Sonstiges</span>
<textarea
value={inUdderOther}
onChange={(event) => setInUdderOther(event.target.value)}
disabled={therapyLocked}
/>
</label>
</article>
<article className="section-card">
<p className="eyebrow">Systemisch</p>
<div className="choice-row choice-row--wrap">
{[...medicationOptions(catalogs, "SYSTEMIC_PAIN"), ...medicationOptions(catalogs, "SYSTEMIC_ANTIBIOTIC")].map(
(medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${systemicMedicationKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() =>
toggleSelection(
systemicMedicationKeys,
medication.businessKey,
setSystemicMedicationKeys,
)
}
disabled={therapyLocked}
>
{medication.name}
</button>
),
)}
</div>
<label className="field">
<span>Sonstiges</span>
<textarea
value={systemicOther}
onChange={(event) => setSystemicOther(event.target.value)}
disabled={therapyLocked}
/>
</label>
</article>
</section>
) : (
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Trockensteller</p>
<h3>Versiegler</h3>
<div className="choice-row choice-row--wrap">
{medicationOptions(catalogs, "DRY_SEALER").map((medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${drySealerKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() => toggleSelection(drySealerKeys, medication.businessKey, setDrySealerKeys)}
disabled={therapyLocked}
>
{medication.name}
</button>
))}
</div>
</article>
<article className="section-card">
<p className="eyebrow">Trockensteller</p>
<h3>Antibiotika</h3>
<div className="choice-row choice-row--wrap">
{medicationOptions(catalogs, "DRY_ANTIBIOTIC").map((medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${dryAntibioticKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() =>
toggleSelection(dryAntibioticKeys, medication.businessKey, setDryAntibioticKeys)
}
disabled={therapyLocked}
>
{medication.name}
</button>
))}
</div>
</article>
</section>
)}
<section className="form-grid">
<article className="section-card">
<label className="field">
<span>Anmerkung fuer Landwirt</span>
<textarea
value={farmerNote}
onChange={(event) => setFarmerNote(event.target.value)}
disabled={therapyLocked}
/>
</label>
</article>
<article className="section-card">
<label className="field">
<span>Interne Bemerkung</span>
<textarea value={internalNote} onChange={(event) => setInternalNote(event.target.value)} />
</label>
</article>
</section>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void handleSave()} disabled={saving}>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</div>
);
}