Initial MUH app implementation
This commit is contained in:
326
frontend/src/pages/AdministrationPage.tsx
Normal file
326
frontend/src/pages/AdministrationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
frontend/src/pages/AnamnesisPage.tsx
Normal file
227
frontend/src/pages/AnamnesisPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
179
frontend/src/pages/HomePage.tsx
Normal file
179
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
frontend/src/pages/LoginPage.tsx
Normal file
255
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
429
frontend/src/pages/PortalPage.tsx
Normal file
429
frontend/src/pages/PortalPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
frontend/src/pages/SampleRegistrationPage.tsx
Normal file
264
frontend/src/pages/SampleRegistrationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
284
frontend/src/pages/TherapyPage.tsx
Normal file
284
frontend/src/pages/TherapyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user