diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java index 723f946..3259eca 100644 --- a/backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java +++ b/backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java @@ -16,6 +16,8 @@ public interface SampleRepository extends MongoRepository { List findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey); + List findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime start, LocalDateTime end); + List findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end); List findByCompletedAtNotNullOrderByCompletedAtDesc(); diff --git a/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java b/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java index 0499bff..f81d8ab 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java @@ -55,6 +55,13 @@ public class PortalService { ); } + public List searchSamplesByCreatedDate(LocalDate date) { + return sampleService.samplesByCreatedDate(date).stream() + .map(this::toPortalRow) + .sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed()) + .toList(); + } + private boolean cowMatches(Sample sample, String cowQuery) { String query = cowQuery.toLowerCase(Locale.ROOT); return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query)) diff --git a/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java b/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java index 6f749e7..cec3a6e 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java @@ -456,6 +456,10 @@ public class SampleService { return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey); } + public List samplesByCreatedDate(LocalDate date) { + return sampleRepository.findByCreatedAtBetweenOrderByCreatedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); + } + public List samplesByDate(LocalDate date) { return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); } diff --git a/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java index 85ba538..2d36743 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java @@ -49,6 +49,11 @@ public class PortalController { return reportService.reportCandidates(); } + @GetMapping("/search/by-date") + public List searchByDate(@RequestParam LocalDate date) { + return portalService.searchSamplesByCreatedDate(date); + } + @PostMapping("/reports/send") public ReportService.DispatchResult send( @RequestHeader(value = "X-MUH-Actor-Id", required = false) String actorId, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2eab8af..5fc4ae7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,9 @@ import AntibiogramPage from "./pages/AntibiogramPage"; import TherapyPage from "./pages/TherapyPage"; import AdministrationPage from "./pages/AdministrationPage"; import PortalPage from "./pages/PortalPage"; +import SearchPage from "./pages/SearchPage"; +import SearchFarmerPage from "./pages/SearchFarmerPage"; +import SearchCalendarPage from "./pages/SearchCalendarPage"; function ProtectedRoutes() { const { user } = useSession(); @@ -26,7 +29,15 @@ function ProtectedRoutes() { } /> } /> } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/SampleSearchResultsSection.tsx b/frontend/src/components/SampleSearchResultsSection.tsx new file mode 100644 index 0000000..d62ca8b --- /dev/null +++ b/frontend/src/components/SampleSearchResultsSection.tsx @@ -0,0 +1,79 @@ +import type { PortalSampleRow } 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)); +} + +type SampleSearchResultsSectionProps = { + eyebrow: string; + title: string; + emptyText: string; + samples: PortalSampleRow[]; + onOpen: (sampleNumber: number) => void; +}; + +export default function SampleSearchResultsSection({ + eyebrow, + title, + emptyText, + samples, + onOpen, +}: SampleSearchResultsSectionProps) { + return ( +
+
+
+

{eyebrow}

+

{title}

+
+
+ + {!samples.length ? ( +
{emptyText}
+ ) : ( +
+ + + + + + + + + + + + + {samples.map((sample) => ( + + + + + + + + + + ))} + +
ProbeErfasstLandwirtKuhTypInterne Bemerkung +
{sample.sampleNumber}{formatDate(sample.createdAt)}{sample.farmerName}{sample.cowNumber}{sample.cowName ? ` / ${sample.cowName}` : ""}{sample.sampleKindLabel === "DRY_OFF" ? "Trockensteller" : "Milchprobe"}{sample.internalNote ?? "-"} + +
+
+ )} +
+ ); +} diff --git a/frontend/src/layout/AppShell.tsx b/frontend/src/layout/AppShell.tsx index c5798f9..3bbe1bc 100644 --- a/frontend/src/layout/AppShell.tsx +++ b/frontend/src/layout/AppShell.tsx @@ -4,7 +4,6 @@ import { useSession } from "../lib/session"; const PAGE_TITLES: Record = { "/home": "Startseite", "/samples/new": "Neuanlage einer Probe", - "/admin": "Verwaltung", "/portal": "MUH-Portal", }; @@ -21,6 +20,27 @@ function resolvePageTitle(pathname: string) { if (pathname.includes("/registration")) { return "Probe bearbeiten"; } + if (pathname.startsWith("/admin/landwirte")) { + return "Verwaltung | Landwirte"; + } + if (pathname.startsWith("/admin/medikamente")) { + return "Verwaltung | Medikamente"; + } + if (pathname.startsWith("/admin/erreger")) { + return "Verwaltung | Erreger"; + } + if (pathname.startsWith("/admin/antibiogramm")) { + return "Verwaltung | Antibiogramm"; + } + if (pathname.startsWith("/search/landwirt")) { + return "Suche | Landwirt"; + } + if (pathname.startsWith("/search/probe")) { + return "Suche | Probe"; + } + if (pathname.startsWith("/search/kalendar")) { + return "Suche | Kalendar"; + } return PAGE_TITLES[pathname] ?? "MUH App"; } @@ -28,14 +48,6 @@ export default function AppShell() { const { user, setUser } = useSession(); const location = useLocation(); const navigate = useNavigate(); - const navItems = user?.role === "ADMIN" - ? [{ to: "/portal", label: "Benutzerverwaltung" }] - : [ - { to: "/home", label: "Start" }, - { to: "/samples/new", label: "Neue Probe" }, - { to: "/admin", label: "Verwaltung" }, - { to: "/portal", label: "Portal" }, - ]; return (
@@ -45,15 +57,60 @@ export default function AppShell() {
diff --git a/frontend/src/pages/AdministrationPage.tsx b/frontend/src/pages/AdministrationPage.tsx index e78b13c..bac786a 100644 --- a/frontend/src/pages/AdministrationPage.tsx +++ b/frontend/src/pages/AdministrationPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useLocation } from "react-router-dom"; import { apiGet, apiPost } from "../lib/api"; import type { AdministrationOverview, MedicationCategory, PathogenKind } from "../lib/types"; @@ -92,10 +93,24 @@ function emptyRow(dataset: DatasetKey): EditableRow { } export default function AdministrationPage() { + const location = useLocation(); const [datasets, setDatasets] = useState(null); - const [selectedDataset, setSelectedDataset] = useState("farmers"); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); + const [showValidation, setShowValidation] = useState(false); + + const selectedDataset: DatasetKey = useMemo(() => { + if (location.pathname.startsWith("/admin/medikamente")) { + return "medications"; + } + if (location.pathname.startsWith("/admin/erreger")) { + return "pathogens"; + } + if (location.pathname.startsWith("/admin/antibiogramm")) { + return "antibiotics"; + } + return "farmers"; + }, [location.pathname]); useEffect(() => { async function load() { @@ -143,6 +158,11 @@ export default function AdministrationPage() { if (!datasets) { return; } + setShowValidation(true); + if (rows.some((row) => !row.name.trim())) { + setMessage("Bitte alle Pflichtfelder ausfuellen."); + return; + } setSaving(true); setMessage(null); try { @@ -216,26 +236,13 @@ export default function AdministrationPage() {

Datensatz

{DATASET_LABELS[selectedDataset]}

- -
- {(Object.keys(DATASET_LABELS) as DatasetKey[]).map((dataset) => ( - - ))} -
- + {selectedDataset === "farmers" ? : null} {selectedDataset === "medications" ? : null} {selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? : null} @@ -248,6 +255,7 @@ export default function AdministrationPage() {
NameNameE-MailKategorieKuerzel
updateRow(index, { name: event.target.value })} /> diff --git a/frontend/src/pages/AnamnesisPage.tsx b/frontend/src/pages/AnamnesisPage.tsx index 158989c..c4c5814 100644 --- a/frontend/src/pages/AnamnesisPage.tsx +++ b/frontend/src/pages/AnamnesisPage.tsx @@ -30,6 +30,7 @@ export default function AnamnesisPage() { const [activeQuarter, setActiveQuarter] = useState(null); const [message, setMessage] = useState(null); const [saving, setSaving] = useState(false); + const [showValidation, setShowValidation] = useState(false); useEffect(() => { async function load() { @@ -70,10 +71,22 @@ export default function AnamnesisPage() { })); } + function quarterHasPathogen(quarterKey: QuarterKey) { + const quarterState = quarterStates[quarterKey]; + return Boolean(quarterState?.pathogenBusinessKey || quarterState?.customPathogenName?.trim()); + } + async function handleSave() { if (!sampleId || !sample) { return; } + setShowValidation(true); + const missingQuarter = sample.quarters.find((quarter) => !quarterHasPathogen(quarter.quarterKey)); + if (missingQuarter) { + setActiveQuarter(missingQuarter.quarterKey); + setMessage("Bitte fuer jede Entnahmestelle einen Erreger auswaehlen oder eingeben."); + return; + } setSaving(true); setMessage(null); @@ -134,7 +147,9 @@ export default function AnamnesisPage() { + + + + {farmers.length > 1 ? ( +
+
+
+

Treffer

+

Gefundene Landwirte

+
+
+
+ {farmers.map((farmer) => ( + + ))} +
+
+ ) : null} + + void openSample(sampleNumber)} + /> + + ); +} diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx new file mode 100644 index 0000000..ca811a0 --- /dev/null +++ b/frontend/src/pages/SearchPage.tsx @@ -0,0 +1,119 @@ +import { FormEvent, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import SampleSearchResultsSection from "../components/SampleSearchResultsSection"; +import { apiGet } from "../lib/api"; +import type { LookupResult, PortalSampleRow, SampleDetail } from "../lib/types"; + +function routeForLookup(result: LookupResult) { + return result.sampleId && result.routeSegment ? `/samples/${result.sampleId}/${result.routeSegment}` : null; +} + +function toPortalRow(sample: SampleDetail): PortalSampleRow { + return { + sampleId: sample.id, + sampleNumber: sample.sampleNumber, + createdAt: sample.createdAt, + completedAt: sample.completedAt, + farmerBusinessKey: sample.farmerBusinessKey, + farmerName: sample.farmerName, + farmerEmail: sample.farmerEmail, + cowNumber: sample.cowNumber, + cowName: sample.cowName, + sampleKindLabel: sample.sampleKind, + internalNote: sample.therapy?.internalNote ?? null, + completed: sample.completed, + reportSent: sample.reportSent, + reportBlocked: sample.reportBlocked, + }; +} + +export default function SearchPage() { + const navigate = useNavigate(); + const [sampleNumber, setSampleNumber] = useState(""); + const [samples, setSamples] = useState([]); + const [message, setMessage] = useState(null); + const [showValidation, setShowValidation] = useState(false); + const [resultLabel, setResultLabel] = useState("Bitte Probennummer eingeben"); + + async function handleSearchByNumber(event: FormEvent) { + event.preventDefault(); + setShowValidation(true); + if (!sampleNumber.trim()) { + setMessage("Bitte eine Probennummer eingeben."); + return; + } + + try { + const result = await apiGet(`/dashboard/lookup/${sampleNumber.trim()}`); + if (!result.found || !result.sampleId) { + setMessage(result.message); + setSamples([]); + setResultLabel("Keine Treffer"); + return; + } + const sample = await apiGet(`/samples/by-number/${sampleNumber.trim()}`); + setSamples([toPortalRow(sample)]); + setResultLabel(`Suchtreffer fuer Probe ${sample.sampleNumber}`); + setMessage(null); + } catch (searchError) { + setSamples([]); + setMessage((searchError as Error).message); + } + } + + async function openSample(sampleNumberToOpen: number) { + try { + const result = await apiGet(`/dashboard/lookup/${sampleNumberToOpen}`); + const target = routeForLookup(result); + if (!result.found || !target) { + setMessage(result.message); + return; + } + navigate(target); + } catch (openError) { + setMessage((openError as Error).message); + } + } + + return ( +
+
+
+

Suche | Probe

+

Probe per Nummer finden

+

+ Suche gezielt nach einer Probennummer und oeffne den passenden Arbeitsschritt direkt aus der Trefferliste. +

+
+ + {message ?
{message}
: null} +
+ +
+
+ + +
+
+ + void openSample(sampleNumberValue)} + /> +
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 8d9e256..ce424ef 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -118,6 +118,33 @@ a { transition: background 160ms ease, color 160ms ease, transform 160ms ease; } +.nav-group { + display: grid; + gap: 8px; +} + +.nav-group__label { + padding: 10px 16px 0; + color: rgba(248, 243, 237, 0.5); + letter-spacing: 0.12em; + text-transform: uppercase; + font-size: 0.74rem; +} + +.nav-subnav { + display: grid; + gap: 8px; + padding-left: 14px; +} + +.nav-sublink { + padding: 10px 14px; + border-radius: 14px; + color: rgba(248, 243, 237, 0.72); + 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); @@ -125,6 +152,13 @@ a { transform: translateX(4px); } +.nav-sublink:hover, +.nav-sublink.is-active { + background: rgba(255, 255, 255, 0.08); + color: #fff8f0; + transform: translateX(4px); +} + .sidebar__footer { display: grid; gap: 12px; @@ -319,6 +353,10 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.field-grid--stacked { + grid-template-columns: 1fr; +} + .field { display: grid; gap: 8px; @@ -333,6 +371,18 @@ a { color: var(--muted); } +.field--required span::after, +.required-label::after { + content: " *"; + color: var(--danger); +} + +.required-label { + margin: 0 0 12px; + color: var(--muted); + font-size: 0.9rem; +} + .field input, .field select, .field textarea, @@ -351,6 +401,21 @@ a { resize: vertical; } +.show-validation .field input:invalid, +.show-validation .field select:invalid, +.show-validation .field textarea:invalid, +.field input.is-invalid, +.field select.is-invalid, +.field textarea.is-invalid, +.data-table input.is-invalid, +.data-table select.is-invalid, +.pathogen-grid.is-invalid, +.choice-row.is-invalid, +.tab-chip.is-invalid { + border-color: rgba(157, 60, 48, 0.55); + box-shadow: 0 0 0 3px rgba(157, 60, 48, 0.12); +} + .choice-row, .tab-row, .page-actions, @@ -385,6 +450,10 @@ a { box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32); } +.tab-chip.is-invalid { + color: var(--danger); +} + .section-card__header { display: flex; align-items: center; @@ -414,6 +483,12 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.pathogen-grid.is-invalid { + padding: 14px; + border: 1px solid rgba(157, 60, 48, 0.3); + border-radius: 18px; +} + .user-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 18px;