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

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MUH App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1786
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "muh-frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "6.23.1"
},
"devDependencies": {
"@types/node": "^25.4.0",
"@types/react": "18.2.66",
"@types/react-dom": "18.2.22",
"@types/scheduler": "^0.26.0",
"@vitejs/plugin-react": "4.2.1",
"typescript": "5.4.5",
"vite": "5.2.10"
}
}

55
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { SessionProvider, useSession } from "./lib/session";
import AppShell from "./layout/AppShell";
import HomePage from "./pages/HomePage";
import LoginPage from "./pages/LoginPage";
import SampleRegistrationPage from "./pages/SampleRegistrationPage";
import AnamnesisPage from "./pages/AnamnesisPage";
import AntibiogramPage from "./pages/AntibiogramPage";
import TherapyPage from "./pages/TherapyPage";
import AdministrationPage from "./pages/AdministrationPage";
import PortalPage from "./pages/PortalPage";
function ProtectedRoutes() {
const { user } = useSession();
if (!user) {
return <Navigate to="/" replace />;
}
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/home" element={<HomePage />} />
<Route path="/samples/new" element={<SampleRegistrationPage />} />
<Route path="/samples/:sampleId/registration" element={<SampleRegistrationPage />} />
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
<Route path="/admin" element={<AdministrationPage />} />
<Route path="/portal" element={<PortalPage />} />
</Route>
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
);
}
function ApplicationRouter() {
const { user } = useSession();
if (!user) {
return (
<Routes>
<Route path="*" element={<LoginPage />} />
</Routes>
);
}
return <ProtectedRoutes />;
}
export default function App() {
return (
<SessionProvider>
<ApplicationRouter />
</SessionProvider>
);
}

1
frontend/src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
interface Worker {}

View File

@@ -0,0 +1,95 @@
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSession } from "../lib/session";
const NAV_ITEMS = [
{ to: "/home", label: "Start" },
{ to: "/samples/new", label: "Neue Probe" },
{ to: "/admin", label: "Verwaltung" },
{ to: "/portal", label: "Portal" },
];
const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite",
"/samples/new": "Neuanlage einer Probe",
"/admin": "Verwaltung",
"/portal": "MUH-Portal",
};
function resolvePageTitle(pathname: string) {
if (pathname.includes("/anamnesis")) {
return "Anamnese";
}
if (pathname.includes("/antibiogram")) {
return "Antibiogramm";
}
if (pathname.includes("/therapy")) {
return "Therapieempfehlung";
}
if (pathname.includes("/registration")) {
return "Probe bearbeiten";
}
return PAGE_TITLES[pathname] ?? "MUH App";
}
export default function AppShell() {
const { user, setUser } = useSession();
const location = useLocation();
const navigate = useNavigate();
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar__brand">
<div className="sidebar__logo">MUH</div>
</div>
<nav className="sidebar__nav">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
{item.label}
</NavLink>
))}
</nav>
<div className="sidebar__footer">
<div className="user-chip user-chip--stacked">
<span>{user?.displayName}</span>
<small>{user?.code}</small>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setUser(null);
navigate("/");
}}
>
Abmelden
</button>
</div>
</aside>
<div className="shell-main">
<header className="topbar">
<div className="topbar__headline">
<h2>{resolvePageTitle(location.pathname)}</h2>
</div>
<div className="topbar__actions">
<button type="button" className="accent-button" onClick={() => navigate("/samples/new")}>
Neuanlage
</button>
</div>
</header>
<main className="content-area">
<Outlet />
</main>
</div>
</div>
);
}

58
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,58 @@
const API_ROOT = import.meta.env.VITE_API_URL ?? "http://localhost:8090/api";
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const text = await response.text();
throw new Error(text || "Unbekannter API-Fehler");
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export async function apiGet<T>(path: string): Promise<T> {
return handleResponse<T>(await fetch(`${API_ROOT}${path}`));
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function apiDelete(path: string): Promise<void> {
await handleResponse<void>(
await fetch(`${API_ROOT}${path}`, {
method: "DELETE",
}),
);
}
export function pdfUrl(sampleId: string): string {
return `${API_ROOT}/portal/reports/${sampleId}/pdf`;
}

View File

@@ -0,0 +1,58 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from "react";
import { USER_STORAGE_KEY } from "./storage";
import type { UserOption } from "./types";
interface SessionContextValue {
user: UserOption | null;
setUser: (user: UserOption | null) => void;
}
const SessionContext = createContext<SessionContextValue>({
user: null,
setUser: () => undefined,
});
function loadStoredUser(): UserOption | null {
const raw = window.localStorage.getItem(USER_STORAGE_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as UserOption;
} catch {
return null;
}
}
export function SessionProvider({ children }: PropsWithChildren) {
const [user, setUserState] = useState<UserOption | null>(() => loadStoredUser());
useEffect(() => {
if (user) {
window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
return;
}
window.localStorage.removeItem(USER_STORAGE_KEY);
}, [user]);
const value = useMemo(
() => ({
user,
setUser: setUserState,
}),
[user],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSession() {
return useContext(SessionContext);
}

View File

@@ -0,0 +1 @@
export const USER_STORAGE_KEY = "muh.current-user";

249
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,249 @@
export type SampleKind = "LACTATION" | "DRY_OFF";
export type SamplingMode = "SINGLE_SITE" | "FOUR_QUARTER" | "UNKNOWN_SITE";
export type SampleWorkflowStep = "ANAMNESIS" | "ANTIBIOGRAM" | "THERAPY" | "COMPLETED";
export type QuarterKey =
| "SINGLE"
| "UNKNOWN"
| "LEFT_FRONT"
| "RIGHT_FRONT"
| "LEFT_REAR"
| "RIGHT_REAR";
export type PathogenKind = "BACTERIAL" | "NO_GROWTH" | "CONTAMINATED" | "OTHER";
export type SensitivityResult = "SENSITIVE" | "INTERMEDIATE" | "RESISTANT";
export type MedicationCategory =
| "IN_UDDER"
| "SYSTEMIC_ANTIBIOTIC"
| "SYSTEMIC_PAIN"
| "DRY_SEALER"
| "DRY_ANTIBIOTIC";
export type UserRole = "APP" | "ADMIN" | "CUSTOMER";
export interface FarmerOption {
businessKey: string;
name: string;
email: string | null;
}
export interface MedicationOption {
businessKey: string;
name: string;
category: MedicationCategory;
}
export interface PathogenOption {
businessKey: string;
code: string | null;
name: string;
kind: PathogenKind;
}
export interface AntibioticOption {
businessKey: string;
code: string | null;
name: string;
}
export interface UserOption {
id: string;
code: string;
displayName: string;
companyName: string | null;
address: string | null;
email: string | null;
portalLogin: string | null;
role: UserRole;
}
export interface UserRow extends UserOption {
active: boolean;
updatedAt: string;
}
export interface ActiveCatalogSummary {
farmers: FarmerOption[];
medications: MedicationOption[];
pathogens: PathogenOption[];
antibiotics: AntibioticOption[];
users: UserOption[];
}
export interface DashboardSampleSummary {
id: string;
sampleNumber: number;
farmerName: string;
cowLabel: string;
sampleKind: SampleKind;
currentStep: SampleWorkflowStep;
updatedAt: string;
reportSent: boolean;
reportBlocked: boolean;
}
export interface DashboardOverview {
nextSampleNumber: number;
openSamples: number;
completedToday: number;
recentSamples: DashboardSampleSummary[];
}
export interface LookupResult {
found: boolean;
message: string;
sampleId: string | null;
step: SampleWorkflowStep | null;
routeSegment: string | null;
}
export interface QuarterView {
quarterKey: QuarterKey;
label: string;
flagged: boolean;
pathogenBusinessKey: string | null;
pathogenCode: string | null;
pathogenName: string | null;
pathogenKind: PathogenKind | null;
customPathogenName: string | null;
cellCount: number | null;
requiresAntibiogram: boolean;
}
export interface AntibiogramEntryView {
antibioticBusinessKey: string;
antibioticCode: string | null;
antibioticName: string;
result: SensitivityResult;
}
export interface AntibiogramView {
quarterKey: QuarterKey;
pathogenName: string;
inheritedFromQuarter: QuarterKey | null;
entries: AntibiogramEntryView[];
}
export interface TherapyView {
continueStarted: boolean;
switchTherapy: boolean;
inUdderMedicationKeys: string[];
inUdderMedicationNames: string[];
inUdderOther: string | null;
systemicMedicationKeys: string[];
systemicMedicationNames: string[];
systemicOther: string | null;
drySealerKeys: string[];
drySealerNames: string[];
dryAntibioticKeys: string[];
dryAntibioticNames: string[];
farmerNote: string | null;
internalNote: string | null;
}
export interface SampleDetail {
id: string;
sampleNumber: number;
farmerBusinessKey: string;
farmerName: string;
farmerEmail: string | null;
cowNumber: string;
cowName: string | null;
sampleKind: SampleKind;
samplingMode: SamplingMode;
currentStep: SampleWorkflowStep;
createdAt: string;
updatedAt: string;
completedAt: string | null;
createdByUserCode: string;
createdByDisplayName: string;
reportSent: boolean;
reportBlocked: boolean;
reportSentAt: string | null;
routeSegment: string;
quarters: QuarterView[];
antibiograms: AntibiogramView[];
therapy: TherapyView | null;
antibiogramTargets: QuarterKey[];
registrationEditable: boolean;
anamnesisEditable: boolean;
antibiogramEditable: boolean;
therapyEditable: boolean;
completed: boolean;
}
export interface FarmerRow {
id: string;
businessKey: string;
name: string;
email: string | null;
active: boolean;
updatedAt: string;
}
export interface MedicationRow {
id: string;
businessKey: string;
name: string;
category: MedicationCategory;
active: boolean;
updatedAt: string;
}
export interface PathogenRow {
id: string;
businessKey: string;
code: string | null;
name: string;
kind: PathogenKind;
active: boolean;
updatedAt: string;
}
export interface AntibioticRow {
id: string;
businessKey: string;
code: string | null;
name: string;
active: boolean;
updatedAt: string;
}
export interface AdministrationOverview {
farmers: FarmerRow[];
medications: MedicationRow[];
pathogens: PathogenRow[];
antibiotics: AntibioticRow[];
}
export interface ReportCandidate {
sampleId: string;
sampleNumber: number;
farmerName: string;
farmerEmail: string;
cowLabel: string;
completedAt: string | null;
reportSent: boolean;
reportBlocked: boolean;
}
export interface PortalSampleRow {
sampleId: string;
sampleNumber: number;
createdAt: string;
completedAt: string | null;
farmerBusinessKey: string;
farmerName: string;
farmerEmail: string | null;
cowNumber: string;
cowName: string | null;
sampleKindLabel: string;
internalNote: string | null;
completed: boolean;
reportSent: boolean;
reportBlocked: boolean;
}
export interface PortalSnapshot {
farmers: FarmerOption[];
samples: PortalSampleRow[];
reportCandidates: ReportCandidate[];
users: UserRow[];
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

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

View File

@@ -0,0 +1,693 @@
@import url("https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&display=swap");
:root {
--bg: #f3ece2;
--bg-deep: #d8cab6;
--surface: rgba(255, 250, 245, 0.84);
--surface-strong: #fff8f0;
--surface-contrast: #25313a;
--line: rgba(37, 49, 58, 0.12);
--text: #1d2428;
--muted: #6e766f;
--accent: #116d63;
--accent-soft: rgba(17, 109, 99, 0.12);
--accent-strong: #0d5b53;
--danger: #9d3c30;
--warning: #8a6500;
--success: #2d6a4f;
--shadow: 0 30px 80px rgba(54, 44, 27, 0.14);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
--radius-sm: 12px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
font-family: "Sora", "Avenir Next", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(17, 109, 99, 0.18), transparent 32%),
radial-gradient(circle at bottom right, rgba(201, 129, 47, 0.18), transparent 28%),
linear-gradient(135deg, var(--bg) 0%, #efe4d5 52%, var(--bg-deep) 100%);
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
}
.app-shell {
display: grid;
grid-template-columns: minmax(228px, 280px) minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 28px 24px;
background: rgba(23, 34, 41, 0.92);
color: #f8f3ed;
backdrop-filter: blur(16px);
}
.sidebar__brand {
display: flex;
gap: 16px;
align-items: center;
width: 100%;
}
.sidebar__brand h1,
.topbar__headline h2,
.section-card h3,
.login-hero h1,
.login-hero h2 {
margin: 0;
}
.sidebar__logo {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 108px;
padding: 16px 24px;
border-radius: 28px;
background: linear-gradient(135deg, #1d9485, #0f5b53);
font-weight: 700;
letter-spacing: 0.08em;
}
.sidebar__nav {
display: grid;
gap: 10px;
margin: 32px 0 auto;
}
.nav-link {
padding: 14px 16px;
border-radius: 16px;
color: rgba(248, 243, 237, 0.78);
text-decoration: none;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.nav-link:hover,
.nav-link.is-active {
background: rgba(255, 255, 255, 0.08);
color: #fff8f0;
transform: translateX(4px);
}
.sidebar__footer {
display: grid;
gap: 12px;
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
width: fit-content;
}
.user-chip--stacked {
display: grid;
gap: 4px;
width: 100%;
border-radius: 18px;
}
.ghost-button,
.secondary-button,
.accent-button,
.menu-toggle,
.choice-chip,
.pathogen-button,
.matrix-button,
.eye-button,
.quarter-tile,
.user-card,
.table-link,
.tab-chip {
border: none;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease, background 150ms ease;
}
.ghost-button,
.menu-toggle,
.secondary-button {
padding: 12px 18px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
color: inherit;
}
.ghost-button:hover,
.menu-toggle:hover,
.secondary-button:hover,
.choice-chip:hover,
.pathogen-button:hover,
.matrix-button:hover,
.eye-button:hover,
.quarter-tile:hover,
.user-card:hover {
transform: translateY(-1px);
}
.accent-button {
padding: 13px 20px;
border-radius: 16px;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fcf7f1;
box-shadow: 0 18px 30px rgba(17, 109, 99, 0.24);
}
.secondary-button {
background: var(--accent-soft);
color: var(--accent-strong);
}
.shell-main {
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 22px 36px;
}
.content-area {
padding: 0 36px 36px;
}
.page-stack {
display: grid;
gap: 24px;
}
.hero-card,
.section-card,
.login-hero__panel {
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: var(--radius-xl);
background: var(--surface);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.section-card,
.hero-card {
padding: 28px;
}
.section-card--hero {
display: grid;
gap: 16px;
}
.hero-card {
display: grid;
gap: 20px;
}
.hero-card__form {
display: flex;
gap: 16px;
align-items: end;
flex-wrap: wrap;
}
.metrics-grid,
.form-grid,
.portal-grid {
display: grid;
gap: 20px;
}
.metrics-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.form-grid,
.portal-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card {
padding: 22px 24px;
border-radius: var(--radius-lg);
background: rgba(255, 248, 240, 0.68);
border: 1px solid rgba(255, 255, 255, 0.32);
box-shadow: var(--shadow);
}
.metric-card__label,
.eyebrow,
.muted-text,
.table-subtext {
display: block;
}
.metric-card strong {
font-size: 2rem;
}
.eyebrow {
margin: 0 0 8px;
color: var(--muted);
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.78rem;
}
.muted-text,
.table-subtext {
color: var(--muted);
font-size: 0.92rem;
}
.field-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field {
display: grid;
gap: 8px;
}
.field span {
font-size: 0.9rem;
color: var(--muted);
}
.field input,
.field select,
.field textarea,
.data-table input,
.data-table select {
width: 100%;
padding: 13px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
color: var(--text);
}
.field textarea {
min-height: 120px;
resize: vertical;
}
.choice-row,
.tab-row,
.page-actions,
.table-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.choice-row--wrap {
align-items: flex-start;
}
.choice-chip,
.tab-chip,
.pathogen-button,
.quarter-tile,
.user-card {
padding: 13px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.74);
color: var(--text);
}
.choice-chip.is-selected,
.tab-chip.is-active,
.pathogen-button.is-selected,
.matrix-button.is-selected,
.quarter-tile.is-flagged,
.user-card {
background: linear-gradient(135deg, rgba(17, 109, 99, 0.16), rgba(17, 109, 99, 0.08));
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32);
}
.section-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.section-card__spacer {
margin-top: 28px;
}
.quarter-grid,
.pathogen-grid,
.user-grid,
.check-list,
.auth-grid {
display: grid;
gap: 14px;
}
.quarter-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.pathogen-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 18px;
}
.auth-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 18px;
}
.login-panel__section {
display: grid;
gap: 14px;
padding: 20px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.56);
border: 1px solid rgba(37, 49, 58, 0.08);
}
.divider-label {
margin: 18px 0 6px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.78rem;
}
.quarter-tile,
.user-card {
display: grid;
gap: 6px;
text-align: left;
}
.quarter-tile strong,
.user-card__code {
font-size: 1.1rem;
}
.check-list__item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.64);
}
.table-shell {
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 14px 12px;
border-bottom: 1px solid rgba(37, 49, 58, 0.08);
text-align: left;
vertical-align: middle;
}
.data-table th {
color: var(--muted);
font-size: 0.82rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-pill,
.info-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
font-size: 0.82rem;
}
.status-pill {
background: rgba(17, 109, 99, 0.08);
color: var(--accent-strong);
}
.status-pill--anamnesis {
background: rgba(34, 113, 190, 0.12);
color: #1a5f9c;
}
.status-pill--antibiogram {
background: rgba(151, 88, 202, 0.12);
color: #6c3fa2;
}
.status-pill--therapy {
background: rgba(214, 138, 6, 0.12);
color: #8a6500;
}
.status-pill--completed {
background: rgba(45, 106, 79, 0.12);
color: var(--success);
}
.info-chip {
background: var(--accent-soft);
color: var(--accent-strong);
}
.matrix-button {
width: 42px;
height: 42px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
}
.eye-button {
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
}
.eye-button.is-active {
color: var(--accent-strong);
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.24);
}
.eye-button.is-inactive {
color: var(--muted);
}
.table-link {
padding: 0;
background: none;
color: var(--accent-strong);
text-decoration: underline;
}
.table-link--danger {
color: var(--danger);
}
.info-panel,
.empty-state,
.alert {
padding: 16px 18px;
border-radius: 18px;
}
.info-panel,
.empty-state {
background: rgba(255, 255, 255, 0.58);
color: var(--muted);
}
.alert {
border: 1px solid transparent;
}
.alert--error {
background: rgba(157, 60, 48, 0.12);
border-color: rgba(157, 60, 48, 0.18);
color: var(--danger);
}
.alert--warning {
background: rgba(138, 101, 0, 0.12);
border-color: rgba(138, 101, 0, 0.16);
color: var(--warning);
}
.alert--success {
background: rgba(45, 106, 79, 0.12);
border-color: rgba(45, 106, 79, 0.16);
color: var(--success);
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px;
}
.login-hero {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 28px;
align-items: stretch;
width: min(1240px, 100%);
}
.login-hero__copy {
position: relative;
padding: 56px;
border-radius: 34px;
overflow: hidden;
background:
linear-gradient(140deg, rgba(14, 33, 36, 0.92), rgba(13, 91, 83, 0.88)),
linear-gradient(120deg, rgba(255, 255, 255, 0.1), transparent);
color: #f8f3ed;
box-shadow: var(--shadow);
}
.login-hero__copy::after {
content: "";
position: absolute;
inset: auto -6% -18% auto;
width: 260px;
height: 260px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
}
.login-hero__panel {
padding: 34px;
}
.panel-glow {
position: absolute;
top: -80px;
right: -60px;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgba(17, 109, 99, 0.16);
filter: blur(8px);
}
.hero-text {
max-width: 520px;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(248, 243, 237, 0.82);
}
.page-actions {
margin-top: 8px;
}
.page-actions--space-between {
justify-content: space-between;
}
.page-actions--align-end {
align-self: end;
justify-content: flex-end;
}
@media (max-width: 1200px) {
.app-shell {
grid-template-columns: 240px minmax(0, 1fr);
}
.login-hero {
grid-template-columns: 1fr;
}
.portal-grid,
.form-grid,
.field-grid,
.metrics-grid {
grid-template-columns: 1fr;
}
.quarter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.sidebar.is-open {
display: flex;
}
.topbar,
.content-area {
padding-inline: 20px;
}
.user-grid,
.auth-grid,
.pathogen-grid,
.quarter-grid {
grid-template-columns: 1fr;
}
}

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2021", "WebWorker"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"lib": ["ES2021", "DOM"],
"types": ["node"]
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

2
frontend/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

9
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: "0.0.0.0",
},
});

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: "0.0.0.0",
},
});