From fb8e3c8ef6895c9553de74f3206c8f735dcb7363 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 12 Mar 2026 11:43:27 +0100 Subject: [PATCH] Initial MUH app implementation --- .gitignore | 4 + .vscode/launch.json | 47 + README.md | 78 + backend/pom.xml | 59 + .../de/svencarstensen/muh/MuhApplication.java | 12 + .../svencarstensen/muh/config/CorsConfig.java | 27 + .../muh/domain/AntibiogramEntry.java | 9 + .../muh/domain/AntibioticCatalogItem.java | 19 + .../de/svencarstensen/muh/domain/AppUser.java | 23 + .../de/svencarstensen/muh/domain/Farmer.java | 19 + .../muh/domain/MedicationCatalogItem.java | 19 + .../muh/domain/MedicationCategory.java | 9 + .../muh/domain/PathogenCatalogItem.java | 20 + .../muh/domain/PathogenKind.java | 8 + .../muh/domain/QuarterAntibiogram.java | 12 + .../muh/domain/QuarterFinding.java | 24 + .../svencarstensen/muh/domain/QuarterKey.java | 20 + .../de/svencarstensen/muh/domain/Sample.java | 33 + .../svencarstensen/muh/domain/SampleKind.java | 6 + .../muh/domain/SampleWorkflowStep.java | 8 + .../muh/domain/SamplingMode.java | 7 + .../muh/domain/SensitivityResult.java | 7 + .../muh/domain/TherapyRecommendation.java | 21 + .../svencarstensen/muh/domain/UserRole.java | 7 + .../AntibioticCatalogRepository.java | 10 + .../muh/repository/AppUserRepository.java | 17 + .../muh/repository/FarmerRepository.java | 12 + .../MedicationCatalogRepository.java | 10 + .../repository/PathogenCatalogRepository.java | 10 + .../muh/repository/SampleRepository.java | 22 + .../muh/service/CatalogService.java | 906 +++++++++ .../muh/service/DemoDataInitializer.java | 76 + .../muh/service/PortalService.java | 127 ++ .../muh/service/ReportService.java | 210 ++ .../muh/service/SampleService.java | 769 +++++++ .../muh/service/SampleWorkflowRules.java | 85 + .../muh/web/CatalogController.java | 76 + .../muh/web/PortalController.java | 78 + .../muh/web/SampleController.java | 66 + .../muh/web/SessionController.java | 61 + backend/src/main/resources/application.yml | 29 + .../muh/service/SampleWorkflowRulesTest.java | 34 + frontend/index.html | 12 + frontend/package-lock.json | 1786 +++++++++++++++++ frontend/package.json | 25 + frontend/src/App.tsx | 55 + frontend/src/globals.d.ts | 1 + frontend/src/layout/AppShell.tsx | 95 + frontend/src/lib/api.ts | 58 + frontend/src/lib/session.tsx | 58 + frontend/src/lib/storage.ts | 1 + frontend/src/lib/types.ts | 249 +++ frontend/src/main.tsx | 13 + frontend/src/pages/AdministrationPage.tsx | 326 +++ frontend/src/pages/AnamnesisPage.tsx | 227 +++ frontend/src/pages/AntibiogramPage.tsx | 219 ++ frontend/src/pages/HomePage.tsx | 179 ++ frontend/src/pages/LoginPage.tsx | 255 +++ frontend/src/pages/PortalPage.tsx | 429 ++++ frontend/src/pages/SampleRegistrationPage.tsx | 264 +++ frontend/src/pages/TherapyPage.tsx | 284 +++ frontend/src/styles/global.css | 693 +++++++ frontend/src/vite-env.d.ts | 7 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 12 + frontend/tsconfig.node.tsbuildinfo | 1 + frontend/vite.config.d.ts | 2 + frontend/vite.config.js | 9 + frontend/vite.config.ts | 10 + 69 files changed, 8387 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 README.md create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/de/svencarstensen/muh/MuhApplication.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/config/CorsConfig.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/AntibiogramEntry.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/AntibioticCatalogItem.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/Farmer.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/MedicationCatalogItem.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/MedicationCategory.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/PathogenCatalogItem.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/PathogenKind.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/QuarterAntibiogram.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/QuarterFinding.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/QuarterKey.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/Sample.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/SampleKind.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/SampleWorkflowStep.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/SamplingMode.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/SensitivityResult.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/TherapyRecommendation.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/UserRole.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/AntibioticCatalogRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/FarmerRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/MedicationCatalogRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/PathogenCatalogRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/DemoDataInitializer.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/PortalService.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/ReportService.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/SampleService.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/SampleWorkflowRules.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/web/PortalController.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/web/SampleController.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/web/SessionController.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/test/java/de/svencarstensen/muh/service/SampleWorkflowRulesTest.java create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/globals.d.ts create mode 100644 frontend/src/layout/AppShell.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/session.tsx create mode 100644 frontend/src/lib/storage.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AdministrationPage.tsx create mode 100644 frontend/src/pages/AnamnesisPage.tsx create mode 100644 frontend/src/pages/AntibiogramPage.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/PortalPage.tsx create mode 100644 frontend/src/pages/SampleRegistrationPage.tsx create mode 100644 frontend/src/pages/TherapyPage.tsx create mode 100644 frontend/src/styles/global.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/tsconfig.node.tsbuildinfo create mode 100644 frontend/vite.config.d.ts create mode 100644 frontend/vite.config.js create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10d353b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +backend/target/ +frontend/node_modules/ +frontend/dist/ +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e70f641 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "Backend: Spring Boot", + "request": "launch", + "mainClass": "de.svencarstensen.muh.MuhApplication", + "projectName": "muh-backend", + "cwd": "${workspaceFolder}/backend", + "console": "integratedTerminal" + }, + { + "type": "node-terminal", + "name": "Frontend: Vite Dev Server", + "request": "launch", + "command": "npm run dev", + "cwd": "${workspaceFolder}/frontend" + }, + { + "type": "pwa-chrome", + "name": "Frontend: Browser", + "request": "launch", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/frontend/src" + } + ], + "compounds": [ + { + "name": "MUH App: Backend + Frontend", + "configurations": [ + "Backend: Spring Boot", + "Frontend: Vite Dev Server" + ], + "stopAll": true + }, + { + "name": "MUH App: Komplett", + "configurations": [ + "Backend: Spring Boot", + "Frontend: Vite Dev Server", + "Frontend: Browser" + ], + "stopAll": true + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..71346ad --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# MUH App + +Spring Boot + React Anwendung fuer die Bearbeitung von Milchproben, Antibiogrammen, +Therapieempfehlungen sowie Verwaltungs- und Portalaufgaben. + +## Projektstruktur + +- `backend/`: Spring Boot REST API mit MongoDB-Anbindung +- `frontend/`: React/Vite Frontend fuer Desktop und Tablet + +## Konfiguration + +MongoDB ist bereits im Backend vorkonfiguriert: + +- `mongodb://192.168.180.25:27017/muh` + +Optional fuer echten Mailversand im Portal: + +- `MUH_MAIL_ENABLED=true` +- `MUH_MAIL_HOST=...` +- `MUH_MAIL_PORT=587` +- `MUH_MAIL_USERNAME=...` +- `MUH_MAIL_PASSWORD=...` +- `MUH_MAIL_FROM=...` +- `MUH_MAIL_AUTH=true` +- `MUH_MAIL_STARTTLS=true` + +Ohne SMTP-Konfiguration markiert das Portal Berichte als versendet, verschickt aber keine E-Mails. + +## Backend starten + +```bash +cd backend +mvn spring-boot:run +``` + +Backend-URL: + +- `http://localhost:8090` + +## Frontend starten + +```bash +cd frontend +npm install +npm run dev +``` + +Frontend-URL: + +- `http://localhost:5173` + +Optional kann die API-URL im Frontend ueber `VITE_API_URL` gesetzt werden. + +## Anmeldung + +Es gibt jetzt drei Varianten: + +- Schnelllogin ueber Benutzerkuerzel +- Login ueber E-Mail oder Benutzername plus Passwort +- Registrierung eines neuen Kundenkontos ueber Firmenname, Adresse, E-Mail und Passwort + +Vordefinierter Admin: + +- Benutzername: `admin` +- E-Mail: `admin@muh.local` +- Passwort: `Admin123!` + +Kundenregistrierung: + +- Die Registrierungsdaten werden dauerhaft in MongoDB in der Collection `users` gespeichert. +- Gespeichert werden `Firmenname`, `Adresse`, `E-Mail`, Passwort-Hash, generierter Loginname und Rolle `CUSTOMER`. +- Nach erfolgreicher Registrierung erfolgt sofort die Anmeldung in der Anwendung. + +## Geprueft + +- `cd backend && mvn test` +- `cd frontend && npm run build` diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..15bfaca --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,59 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + de.svencarstensen + muh-backend + 0.0.1-SNAPSHOT + muh-backend + MUH application backend + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.security + spring-security-crypto + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/de/svencarstensen/muh/MuhApplication.java b/backend/src/main/java/de/svencarstensen/muh/MuhApplication.java new file mode 100644 index 0000000..1c8b0c2 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/MuhApplication.java @@ -0,0 +1,12 @@ +package de.svencarstensen.muh; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MuhApplication { + + public static void main(String[] args) { + SpringApplication.run(MuhApplication.class, args); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/config/CorsConfig.java b/backend/src/main/java/de/svencarstensen/muh/config/CorsConfig.java new file mode 100644 index 0000000..14b1ecd --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/config/CorsConfig.java @@ -0,0 +1,27 @@ +package de.svencarstensen.muh.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Bean + CorsFilter corsFilter(@Value("${muh.cors.allowed-origins}") List allowedOrigins) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(allowedOrigins); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + configuration.setAllowCredentials(false); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + return new CorsFilter(source); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/AntibiogramEntry.java b/backend/src/main/java/de/svencarstensen/muh/domain/AntibiogramEntry.java new file mode 100644 index 0000000..9712680 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/AntibiogramEntry.java @@ -0,0 +1,9 @@ +package de.svencarstensen.muh.domain; + +public record AntibiogramEntry( + String antibioticBusinessKey, + String antibioticCode, + String antibioticName, + SensitivityResult result +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/AntibioticCatalogItem.java b/backend/src/main/java/de/svencarstensen/muh/domain/AntibioticCatalogItem.java new file mode 100644 index 0000000..22b69e4 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/AntibioticCatalogItem.java @@ -0,0 +1,19 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document("antibiotics") +public record AntibioticCatalogItem( + @Id String id, + String businessKey, + String code, + String name, + boolean active, + String supersedesId, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java b/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java new file mode 100644 index 0000000..0508920 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java @@ -0,0 +1,23 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document("users") +public record AppUser( + @Id String id, + String code, + String displayName, + String companyName, + String address, + String email, + String portalLogin, + String passwordHash, + boolean active, + UserRole role, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/Farmer.java b/backend/src/main/java/de/svencarstensen/muh/domain/Farmer.java new file mode 100644 index 0000000..fc37fd2 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/Farmer.java @@ -0,0 +1,19 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document("farmers") +public record Farmer( + @Id String id, + String businessKey, + String name, + String email, + boolean active, + String supersedesId, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/MedicationCatalogItem.java b/backend/src/main/java/de/svencarstensen/muh/domain/MedicationCatalogItem.java new file mode 100644 index 0000000..3468b64 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/MedicationCatalogItem.java @@ -0,0 +1,19 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document("medications") +public record MedicationCatalogItem( + @Id String id, + String businessKey, + String name, + MedicationCategory category, + boolean active, + String supersedesId, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/MedicationCategory.java b/backend/src/main/java/de/svencarstensen/muh/domain/MedicationCategory.java new file mode 100644 index 0000000..12db0e0 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/MedicationCategory.java @@ -0,0 +1,9 @@ +package de.svencarstensen.muh.domain; + +public enum MedicationCategory { + IN_UDDER, + SYSTEMIC_ANTIBIOTIC, + SYSTEMIC_PAIN, + DRY_SEALER, + DRY_ANTIBIOTIC +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/PathogenCatalogItem.java b/backend/src/main/java/de/svencarstensen/muh/domain/PathogenCatalogItem.java new file mode 100644 index 0000000..0c110d2 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/PathogenCatalogItem.java @@ -0,0 +1,20 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document("pathogens") +public record PathogenCatalogItem( + @Id String id, + String businessKey, + String code, + String name, + PathogenKind kind, + boolean active, + String supersedesId, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/PathogenKind.java b/backend/src/main/java/de/svencarstensen/muh/domain/PathogenKind.java new file mode 100644 index 0000000..5ce69ec --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/PathogenKind.java @@ -0,0 +1,8 @@ +package de.svencarstensen.muh.domain; + +public enum PathogenKind { + BACTERIAL, + NO_GROWTH, + CONTAMINATED, + OTHER +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/QuarterAntibiogram.java b/backend/src/main/java/de/svencarstensen/muh/domain/QuarterAntibiogram.java new file mode 100644 index 0000000..87478bc --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/QuarterAntibiogram.java @@ -0,0 +1,12 @@ +package de.svencarstensen.muh.domain; + +import java.util.List; + +public record QuarterAntibiogram( + QuarterKey quarterKey, + String pathogenBusinessKey, + String pathogenName, + QuarterKey inheritedFromQuarter, + List entries +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/QuarterFinding.java b/backend/src/main/java/de/svencarstensen/muh/domain/QuarterFinding.java new file mode 100644 index 0000000..a8d98d4 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/QuarterFinding.java @@ -0,0 +1,24 @@ +package de.svencarstensen.muh.domain; + +public record QuarterFinding( + QuarterKey quarterKey, + boolean flagged, + String pathogenBusinessKey, + String pathogenCode, + String pathogenName, + PathogenKind pathogenKind, + String customPathogenName, + Integer cellCount +) { + + public boolean requiresAntibiogram() { + return pathogenKind == PathogenKind.BACTERIAL; + } + + public String effectivePathogenLabel() { + if (customPathogenName != null && !customPathogenName.isBlank()) { + return customPathogenName.trim(); + } + return pathogenName; + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/QuarterKey.java b/backend/src/main/java/de/svencarstensen/muh/domain/QuarterKey.java new file mode 100644 index 0000000..3247851 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/QuarterKey.java @@ -0,0 +1,20 @@ +package de.svencarstensen.muh.domain; + +public enum QuarterKey { + SINGLE("Einzelprobe"), + UNKNOWN("Unbekannt"), + LEFT_FRONT("Vorne links"), + RIGHT_FRONT("Vorne rechts"), + LEFT_REAR("Hinten links"), + RIGHT_REAR("Hinten rechts"); + + private final String label; + + QuarterKey(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/Sample.java b/backend/src/main/java/de/svencarstensen/muh/domain/Sample.java new file mode 100644 index 0000000..ee98772 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/Sample.java @@ -0,0 +1,33 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.List; + +@Document("samples") +public record Sample( + @Id String id, + long sampleNumber, + String farmerBusinessKey, + String farmerName, + String farmerEmail, + String cowNumber, + String cowName, + SampleKind sampleKind, + SamplingMode samplingMode, + SampleWorkflowStep currentStep, + List quarters, + List antibiograms, + TherapyRecommendation therapyRecommendation, + boolean reportSent, + boolean reportBlocked, + LocalDateTime reportSentAt, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime completedAt, + String createdByUserCode, + String createdByDisplayName +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/SampleKind.java b/backend/src/main/java/de/svencarstensen/muh/domain/SampleKind.java new file mode 100644 index 0000000..d77b5f7 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/SampleKind.java @@ -0,0 +1,6 @@ +package de.svencarstensen.muh.domain; + +public enum SampleKind { + LACTATION, + DRY_OFF +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/SampleWorkflowStep.java b/backend/src/main/java/de/svencarstensen/muh/domain/SampleWorkflowStep.java new file mode 100644 index 0000000..ab94d80 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/SampleWorkflowStep.java @@ -0,0 +1,8 @@ +package de.svencarstensen.muh.domain; + +public enum SampleWorkflowStep { + ANAMNESIS, + ANTIBIOGRAM, + THERAPY, + COMPLETED +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/SamplingMode.java b/backend/src/main/java/de/svencarstensen/muh/domain/SamplingMode.java new file mode 100644 index 0000000..09267fc --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/SamplingMode.java @@ -0,0 +1,7 @@ +package de.svencarstensen.muh.domain; + +public enum SamplingMode { + SINGLE_SITE, + FOUR_QUARTER, + UNKNOWN_SITE +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/SensitivityResult.java b/backend/src/main/java/de/svencarstensen/muh/domain/SensitivityResult.java new file mode 100644 index 0000000..2e36db9 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/SensitivityResult.java @@ -0,0 +1,7 @@ +package de.svencarstensen.muh.domain; + +public enum SensitivityResult { + SENSITIVE, + INTERMEDIATE, + RESISTANT +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/TherapyRecommendation.java b/backend/src/main/java/de/svencarstensen/muh/domain/TherapyRecommendation.java new file mode 100644 index 0000000..83ef8ad --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/TherapyRecommendation.java @@ -0,0 +1,21 @@ +package de.svencarstensen.muh.domain; + +import java.util.List; + +public record TherapyRecommendation( + boolean continueStarted, + boolean switchTherapy, + List inUdderMedicationKeys, + List inUdderMedicationNames, + String inUdderOther, + List systemicMedicationKeys, + List systemicMedicationNames, + String systemicOther, + List drySealerKeys, + List drySealerNames, + List dryAntibioticKeys, + List dryAntibioticNames, + String farmerNote, + String internalNote +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/UserRole.java b/backend/src/main/java/de/svencarstensen/muh/domain/UserRole.java new file mode 100644 index 0000000..560ee39 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/UserRole.java @@ -0,0 +1,7 @@ +package de.svencarstensen.muh.domain; + +public enum UserRole { + APP, + ADMIN, + CUSTOMER +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/AntibioticCatalogRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/AntibioticCatalogRepository.java new file mode 100644 index 0000000..64f8107 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/AntibioticCatalogRepository.java @@ -0,0 +1,10 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.AntibioticCatalogItem; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; + +public interface AntibioticCatalogRepository extends MongoRepository { + List findByActiveTrueOrderByNameAsc(); +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java new file mode 100644 index 0000000..a32cfc9 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java @@ -0,0 +1,17 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.AppUser; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; +import java.util.Optional; + +public interface AppUserRepository extends MongoRepository { + List findByActiveTrueOrderByDisplayNameAsc(); + + Optional findByCodeIgnoreCase(String code); + + Optional findByEmailIgnoreCase(String email); + + Optional findByPortalLoginIgnoreCase(String portalLogin); +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/FarmerRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/FarmerRepository.java new file mode 100644 index 0000000..741cec0 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/FarmerRepository.java @@ -0,0 +1,12 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.Farmer; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; + +public interface FarmerRepository extends MongoRepository { + List findByActiveTrueOrderByNameAsc(); + + List findByNameContainingIgnoreCaseOrderByNameAsc(String name); +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/MedicationCatalogRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/MedicationCatalogRepository.java new file mode 100644 index 0000000..867777c --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/MedicationCatalogRepository.java @@ -0,0 +1,10 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.MedicationCatalogItem; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; + +public interface MedicationCatalogRepository extends MongoRepository { + List findByActiveTrueOrderByNameAsc(); +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/PathogenCatalogRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/PathogenCatalogRepository.java new file mode 100644 index 0000000..10f557c --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/PathogenCatalogRepository.java @@ -0,0 +1,10 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.PathogenCatalogItem; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; + +public interface PathogenCatalogRepository extends MongoRepository { + List findByActiveTrueOrderByNameAsc(); +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java new file mode 100644 index 0000000..723f946 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/SampleRepository.java @@ -0,0 +1,22 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.Sample; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface SampleRepository extends MongoRepository { + Optional findBySampleNumber(long sampleNumber); + + Optional findTopByOrderBySampleNumberDesc(); + + List findTop12ByOrderByUpdatedAtDesc(); + + List findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey); + + List findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end); + + List findByCompletedAtNotNullOrderByCompletedAtDesc(); +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java new file mode 100644 index 0000000..c7f5182 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java @@ -0,0 +1,906 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.AntibioticCatalogItem; +import de.svencarstensen.muh.domain.AppUser; +import de.svencarstensen.muh.domain.Farmer; +import de.svencarstensen.muh.domain.MedicationCatalogItem; +import de.svencarstensen.muh.domain.MedicationCategory; +import de.svencarstensen.muh.domain.PathogenCatalogItem; +import de.svencarstensen.muh.domain.PathogenKind; +import de.svencarstensen.muh.domain.UserRole; +import de.svencarstensen.muh.repository.AntibioticCatalogRepository; +import de.svencarstensen.muh.repository.AppUserRepository; +import de.svencarstensen.muh.repository.FarmerRepository; +import de.svencarstensen.muh.repository.MedicationCatalogRepository; +import de.svencarstensen.muh.repository.PathogenCatalogRepository; +import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class CatalogService { + + private static final Comparator FARMER_ROW_COMPARATOR = Comparator + .comparing(FarmerRow::active).reversed() + .thenComparing(FarmerRow::name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(FarmerRow::updatedAt, Comparator.nullsLast(Comparator.reverseOrder())); + + private static final Comparator MEDICATION_ROW_COMPARATOR = Comparator + .comparing(MedicationRow::active).reversed() + .thenComparing(MedicationRow::category) + .thenComparing(MedicationRow::name, String.CASE_INSENSITIVE_ORDER); + + private static final Comparator PATHOGEN_ROW_COMPARATOR = Comparator + .comparing(PathogenRow::active).reversed() + .thenComparing(PathogenRow::name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(PathogenRow::code, String.CASE_INSENSITIVE_ORDER); + + private static final Comparator ANTIBIOTIC_ROW_COMPARATOR = Comparator + .comparing(AntibioticRow::active).reversed() + .thenComparing(AntibioticRow::name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(AntibioticRow::code, String.CASE_INSENSITIVE_ORDER); + + private final FarmerRepository farmerRepository; + private final MedicationCatalogRepository medicationRepository; + private final PathogenCatalogRepository pathogenRepository; + private final AntibioticCatalogRepository antibioticRepository; + private final AppUserRepository appUserRepository; + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + public CatalogService( + FarmerRepository farmerRepository, + MedicationCatalogRepository medicationRepository, + PathogenCatalogRepository pathogenRepository, + AntibioticCatalogRepository antibioticRepository, + AppUserRepository appUserRepository + ) { + this.farmerRepository = farmerRepository; + this.medicationRepository = medicationRepository; + this.pathogenRepository = pathogenRepository; + this.antibioticRepository = antibioticRepository; + this.appUserRepository = appUserRepository; + } + + public ActiveCatalogSummary activeCatalogSummary() { + return new ActiveCatalogSummary( + farmerRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toFarmerOption).toList(), + medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(), + pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(), + antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).toList(), + activeQuickLoginUsers().stream().map(this::toUserOption).toList() + ); + } + + public AdministrationOverview administrationOverview() { + return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows()); + } + + public List listFarmerRows() { + return farmerRepository.findAll().stream() + .map(this::toFarmerRow) + .sorted(FARMER_ROW_COMPARATOR) + .toList(); + } + + public List listMedicationRows() { + return medicationRepository.findAll().stream() + .map(this::toMedicationRow) + .sorted(MEDICATION_ROW_COMPARATOR) + .toList(); + } + + public List listPathogenRows() { + return pathogenRepository.findAll().stream() + .map(this::toPathogenRow) + .sorted(PATHOGEN_ROW_COMPARATOR) + .toList(); + } + + public List listAntibioticRows() { + return antibioticRepository.findAll().stream() + .map(this::toAntibioticRow) + .sorted(ANTIBIOTIC_ROW_COMPARATOR) + .toList(); + } + + public List saveFarmers(List mutations) { + for (FarmerMutation mutation : mutations) { + if (isBlank(mutation.name())) { + continue; + } + LocalDateTime now = LocalDateTime.now(); + if (isBlank(mutation.id())) { + farmerRepository.save(new Farmer( + null, + UUID.randomUUID().toString(), + mutation.name().trim(), + blankToNull(mutation.email()), + mutation.active(), + null, + now, + now + )); + continue; + } + String mutationId = requireText(mutation.id(), "Landwirt-ID fehlt"); + Farmer existing = farmerRepository.findById(mutationId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Landwirt nicht gefunden")); + boolean changed = !existing.name().equals(mutation.name().trim()) + || !safeEquals(existing.email(), blankToNull(mutation.email())); + if (changed) { + farmerRepository.save(new Farmer( + existing.id(), + existing.businessKey(), + existing.name(), + existing.email(), + false, + existing.supersedesId(), + existing.createdAt(), + now + )); + farmerRepository.save(new Farmer( + null, + existing.businessKey(), + mutation.name().trim(), + blankToNull(mutation.email()), + mutation.active(), + existing.id(), + now, + now + )); + continue; + } + if (existing.active() != mutation.active()) { + farmerRepository.save(new Farmer( + existing.id(), + existing.businessKey(), + existing.name(), + existing.email(), + mutation.active(), + existing.supersedesId(), + existing.createdAt(), + now + )); + } + } + return listFarmerRows(); + } + + public List saveMedications(List mutations) { + for (MedicationMutation mutation : mutations) { + if (isBlank(mutation.name()) || mutation.category() == null) { + continue; + } + LocalDateTime now = LocalDateTime.now(); + if (isBlank(mutation.id())) { + medicationRepository.save(new MedicationCatalogItem( + null, + UUID.randomUUID().toString(), + mutation.name().trim(), + mutation.category(), + mutation.active(), + null, + now, + now + )); + continue; + } + String mutationId = requireText(mutation.id(), "Medikament-ID fehlt"); + MedicationCatalogItem existing = medicationRepository.findById(mutationId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Medikament nicht gefunden")); + boolean changed = !existing.name().equals(mutation.name().trim()) + || existing.category() != mutation.category(); + if (changed) { + medicationRepository.save(new MedicationCatalogItem( + existing.id(), + existing.businessKey(), + existing.name(), + existing.category(), + false, + existing.supersedesId(), + existing.createdAt(), + now + )); + medicationRepository.save(new MedicationCatalogItem( + null, + existing.businessKey(), + mutation.name().trim(), + mutation.category(), + mutation.active(), + existing.id(), + now, + now + )); + continue; + } + if (existing.active() != mutation.active()) { + medicationRepository.save(new MedicationCatalogItem( + existing.id(), + existing.businessKey(), + existing.name(), + existing.category(), + mutation.active(), + existing.supersedesId(), + existing.createdAt(), + now + )); + } + } + return listMedicationRows(); + } + + public List savePathogens(List mutations) { + for (PathogenMutation mutation : mutations) { + if (isBlank(mutation.name()) || mutation.kind() == null) { + continue; + } + LocalDateTime now = LocalDateTime.now(); + if (isBlank(mutation.id())) { + pathogenRepository.save(new PathogenCatalogItem( + null, + UUID.randomUUID().toString(), + blankToNull(mutation.code()), + mutation.name().trim(), + mutation.kind(), + mutation.active(), + null, + now, + now + )); + continue; + } + String mutationId = requireText(mutation.id(), "Erreger-ID fehlt"); + PathogenCatalogItem existing = pathogenRepository.findById(mutationId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Erreger nicht gefunden")); + boolean changed = !existing.name().equals(mutation.name().trim()) + || !safeEquals(existing.code(), blankToNull(mutation.code())) + || existing.kind() != mutation.kind(); + if (changed) { + pathogenRepository.save(new PathogenCatalogItem( + existing.id(), + existing.businessKey(), + existing.code(), + existing.name(), + existing.kind(), + false, + existing.supersedesId(), + existing.createdAt(), + now + )); + pathogenRepository.save(new PathogenCatalogItem( + null, + existing.businessKey(), + blankToNull(mutation.code()), + mutation.name().trim(), + mutation.kind(), + mutation.active(), + existing.id(), + now, + now + )); + continue; + } + if (existing.active() != mutation.active()) { + pathogenRepository.save(new PathogenCatalogItem( + existing.id(), + existing.businessKey(), + existing.code(), + existing.name(), + existing.kind(), + mutation.active(), + existing.supersedesId(), + existing.createdAt(), + now + )); + } + } + return listPathogenRows(); + } + + public List saveAntibiotics(List mutations) { + for (AntibioticMutation mutation : mutations) { + if (isBlank(mutation.name())) { + continue; + } + LocalDateTime now = LocalDateTime.now(); + if (isBlank(mutation.id())) { + antibioticRepository.save(new AntibioticCatalogItem( + null, + UUID.randomUUID().toString(), + blankToNull(mutation.code()), + mutation.name().trim(), + mutation.active(), + null, + now, + now + )); + continue; + } + String mutationId = requireText(mutation.id(), "Antibiotika-ID fehlt"); + AntibioticCatalogItem existing = antibioticRepository.findById(mutationId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Antibiotikum nicht gefunden")); + boolean changed = !existing.name().equals(mutation.name().trim()) + || !safeEquals(existing.code(), blankToNull(mutation.code())); + if (changed) { + antibioticRepository.save(new AntibioticCatalogItem( + existing.id(), + existing.businessKey(), + existing.code(), + existing.name(), + false, + existing.supersedesId(), + existing.createdAt(), + now + )); + antibioticRepository.save(new AntibioticCatalogItem( + null, + existing.businessKey(), + blankToNull(mutation.code()), + mutation.name().trim(), + mutation.active(), + existing.id(), + now, + now + )); + continue; + } + if (existing.active() != mutation.active()) { + antibioticRepository.save(new AntibioticCatalogItem( + existing.id(), + existing.businessKey(), + existing.code(), + existing.name(), + mutation.active(), + existing.supersedesId(), + existing.createdAt(), + now + )); + } + } + return listAntibioticRows(); + } + + public List listUsers() { + ensureDefaultUsers(); + return appUserRepository.findAll().stream() + .map(this::toUserRow) + .sorted(Comparator.comparing(UserRow::active).reversed().thenComparing(UserRow::displayName, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } + + public UserRow createOrUpdateUser(UserMutation mutation) { + if (isBlank(mutation.displayName()) || isBlank(mutation.code())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername und Kürzel sind erforderlich"); + } + LocalDateTime now = LocalDateTime.now(); + validateUserMutation(mutation); + if (isBlank(mutation.id())) { + AppUser created = appUserRepository.save(new AppUser( + null, + mutation.code().trim().toUpperCase(), + mutation.displayName().trim(), + blankToNull(mutation.companyName()), + blankToNull(mutation.address()), + normalizeEmail(mutation.email()), + blankToNull(mutation.portalLogin()), + encodeIfPresent(mutation.password()), + mutation.active(), + Optional.ofNullable(mutation.role()).orElse(UserRole.APP), + now, + now + )); + return toUserRow(created); + } + String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt"); + AppUser existing = appUserRepository.findById(mutationId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden")); + AppUser saved = appUserRepository.save(new AppUser( + existing.id(), + mutation.code().trim().toUpperCase(), + mutation.displayName().trim(), + blankToNull(mutation.companyName()), + blankToNull(mutation.address()), + normalizeEmail(mutation.email()), + blankToNull(mutation.portalLogin()), + isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()), + mutation.active(), + Optional.ofNullable(mutation.role()).orElse(existing.role()), + existing.createdAt(), + now + )); + return toUserRow(saved); + } + + public void deleteUser(String id) { + appUserRepository.deleteById(requireText(id, "Benutzer-ID fehlt")); + } + + public void changePassword(String id, String newPassword) { + if (isBlank(newPassword)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Passwort darf nicht leer sein"); + } + AppUser existing = appUserRepository.findById(requireText(id, "Benutzer-ID fehlt")) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden")); + appUserRepository.save(new AppUser( + existing.id(), + existing.code(), + existing.displayName(), + existing.companyName(), + existing.address(), + existing.email(), + existing.portalLogin(), + passwordEncoder.encode(newPassword), + existing.active(), + existing.role(), + existing.createdAt(), + LocalDateTime.now() + )); + } + + public UserOption loginByCode(String code) { + AppUser user = activeQuickLoginUsers().stream() + .filter(candidate -> candidate.code().equalsIgnoreCase(code)) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzerkürzel unbekannt")); + return toUserOption(user); + } + + public UserOption loginWithPassword(String identifier, String password) { + if (isBlank(identifier) || isBlank(password)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername/E-Mail und Passwort sind erforderlich"); + } + + AppUser user = resolvePasswordUser(identifier.trim()) + .filter(AppUser::active) + .filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash())) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Anmeldung fehlgeschlagen")); + return toUserOption(user); + } + + public UserOption registerCustomer(RegistrationMutation mutation) { + if (isBlank(mutation.companyName()) || isBlank(mutation.address()) || isBlank(mutation.email()) || isBlank(mutation.password())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Firmenname, Adresse, E-Mail und Passwort sind erforderlich"); + } + + String normalizedEmail = normalizeEmail(mutation.email()); + if (appUserRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Diese E-Mail-Adresse ist bereits registriert"); + } + + String companyName = mutation.companyName().trim(); + String address = mutation.address().trim(); + String displayName = companyName; + String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail)); + String code = generateUniqueCode("K" + companyName); + LocalDateTime now = LocalDateTime.now(); + + AppUser created = appUserRepository.save(new AppUser( + null, + code, + displayName, + companyName, + address, + normalizedEmail, + portalLogin, + passwordEncoder.encode(mutation.password()), + true, + UserRole.CUSTOMER, + now, + now + )); + return toUserOption(created); + } + + private List activeUsers() { + ensureDefaultUsers(); + return appUserRepository.findByActiveTrueOrderByDisplayNameAsc(); + } + + private List activeQuickLoginUsers() { + return activeUsers().stream() + .filter(user -> user.role() != UserRole.CUSTOMER) + .toList(); + } + + public void ensureDefaultUsers() { + ensureDefaultUser("ADM", "Administrator", "admin@muh.local", "admin", "Admin123!", UserRole.ADMIN); + ensureDefaultUser("SV", "Sven", "sven@muh.local", "sven", "muh123", UserRole.APP); + ensureDefaultUser("AK", "Anna", "anna@muh.local", "anna", "muh123", UserRole.APP); + ensureDefaultUser("LH", "Lena", "lena@muh.local", "lena", "muh123", UserRole.APP); + } + + public Farmer requireActiveFarmer(String businessKey) { + return farmerRepository.findByActiveTrueOrderByNameAsc().stream() + .filter(farmer -> farmer.businessKey().equals(businessKey)) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); + } + + public Map activePathogensByBusinessKey() { + return pathogenRepository.findByActiveTrueOrderByNameAsc().stream() + .collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity())); + } + + public Map activeAntibioticsByBusinessKey() { + return antibioticRepository.findByActiveTrueOrderByNameAsc().stream() + .collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity())); + } + + public Map activeMedicationsByBusinessKey() { + return medicationRepository.findByActiveTrueOrderByNameAsc().stream() + .collect(Collectors.toMap(MedicationCatalogItem::businessKey, Function.identity())); + } + + private FarmerRow toFarmerRow(Farmer farmer) { + return new FarmerRow( + farmer.id(), + farmer.businessKey(), + farmer.name(), + farmer.email(), + farmer.active(), + farmer.updatedAt() + ); + } + + private MedicationRow toMedicationRow(MedicationCatalogItem item) { + return new MedicationRow( + item.id(), + item.businessKey(), + item.name(), + item.category(), + item.active(), + item.updatedAt() + ); + } + + private PathogenRow toPathogenRow(PathogenCatalogItem item) { + return new PathogenRow( + item.id(), + item.businessKey(), + item.code(), + item.name(), + item.kind(), + item.active(), + item.updatedAt() + ); + } + + private AntibioticRow toAntibioticRow(AntibioticCatalogItem item) { + return new AntibioticRow( + item.id(), + item.businessKey(), + item.code(), + item.name(), + item.active(), + item.updatedAt() + ); + } + + private UserRow toUserRow(AppUser user) { + return new UserRow( + user.id(), + user.code(), + user.displayName(), + user.companyName(), + user.address(), + user.email(), + user.portalLogin(), + user.active(), + user.role(), + user.updatedAt() + ); + } + + private FarmerOption toFarmerOption(Farmer farmer) { + return new FarmerOption(farmer.businessKey(), farmer.name(), farmer.email()); + } + + private MedicationOption toMedicationOption(MedicationCatalogItem item) { + return new MedicationOption(item.businessKey(), item.name(), item.category()); + } + + private PathogenOption toPathogenOption(PathogenCatalogItem item) { + return new PathogenOption(item.businessKey(), item.code(), item.name(), item.kind()); + } + + private AntibioticOption toAntibioticOption(AntibioticCatalogItem item) { + return new AntibioticOption(item.businessKey(), item.code(), item.name()); + } + + private UserOption toUserOption(AppUser user) { + return new UserOption( + user.id(), + user.code(), + user.displayName(), + user.companyName(), + user.address(), + user.email(), + user.portalLogin(), + user.role() + ); + } + + private String encodeIfPresent(String password) { + return isBlank(password) ? null : passwordEncoder.encode(password); + } + + private @NonNull String requireText(String value, String message) { + if (isBlank(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + String sanitized = Objects.requireNonNull(value).trim(); + return Objects.requireNonNull(sanitized); + } + + private void validateUserMutation(UserMutation mutation) { + String normalizedEmail = normalizeEmail(mutation.email()); + String normalizedLogin = blankToNull(mutation.portalLogin()); + String normalizedCode = mutation.code().trim().toUpperCase(Locale.ROOT); + + appUserRepository.findAll().forEach(existing -> { + if (!safeEquals(existing.id(), blankToNull(mutation.id())) + && existing.code() != null + && existing.code().equalsIgnoreCase(normalizedCode)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Dieses Kürzel ist bereits vergeben"); + } + if (normalizedEmail != null + && existing.email() != null + && !safeEquals(existing.id(), blankToNull(mutation.id())) + && existing.email().equalsIgnoreCase(normalizedEmail)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Diese E-Mail-Adresse ist bereits vergeben"); + } + if (normalizedLogin != null + && existing.portalLogin() != null + && !safeEquals(existing.id(), blankToNull(mutation.id())) + && existing.portalLogin().equalsIgnoreCase(normalizedLogin)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Dieser Benutzername ist bereits vergeben"); + } + }); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String blankToNull(String value) { + return isBlank(value) ? null : value.trim(); + } + + private boolean safeEquals(String left, String right) { + return left == null ? right == null : left.equals(right); + } + + private Optional resolvePasswordUser(String identifier) { + return appUserRepository.findByEmailIgnoreCase(identifier) + .or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier)); + } + + private void ensureDefaultUser( + String code, + String displayName, + String email, + String portalLogin, + String rawPassword, + UserRole role + ) { + boolean exists = appUserRepository.findByCodeIgnoreCase(code).isPresent() + || appUserRepository.findByEmailIgnoreCase(email).isPresent() + || appUserRepository.findByPortalLoginIgnoreCase(portalLogin).isPresent(); + if (exists) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + appUserRepository.save(new AppUser( + null, + code, + displayName, + null, + null, + email, + portalLogin, + passwordEncoder.encode(rawPassword), + true, + role, + now, + now + )); + } + + private String normalizeEmail(String email) { + return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT); + } + + private String localPart(String email) { + int separator = email.indexOf('@'); + return separator >= 0 ? email.substring(0, separator) : email; + } + + private String generateUniquePortalLogin(String seed) { + String base = seed.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]", ""); + if (base.isBlank()) { + base = "user"; + } + + String candidate = base; + int index = 2; + while (appUserRepository.findByPortalLoginIgnoreCase(candidate).isPresent()) { + candidate = base + index++; + } + return candidate; + } + + private String generateUniqueCode(String seed) { + String compact = seed.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); + if (compact.isBlank()) { + compact = "USR"; + } + + String base = compact.length() >= 3 ? compact.substring(0, Math.min(4, compact.length())) : (compact + "XXX").substring(0, 3); + Set usedCodes = appUserRepository.findAll().stream() + .map(AppUser::code) + .filter(code -> code != null && !code.isBlank()) + .map(code -> code.toUpperCase(Locale.ROOT)) + .collect(Collectors.toCollection(HashSet::new)); + + String candidate = base.length() > 3 ? base.substring(0, 3) : base; + if (!usedCodes.contains(candidate)) { + return candidate; + } + + String prefix = candidate.substring(0, Math.min(2, candidate.length())); + int index = 1; + while (true) { + String numbered = (prefix + index).toUpperCase(Locale.ROOT); + if (!usedCodes.contains(numbered)) { + return numbered; + } + index++; + } + } + + public record ActiveCatalogSummary( + List farmers, + List medications, + List pathogens, + List antibiotics, + List users + ) { + } + + public record AdministrationOverview( + List farmers, + List medications, + List pathogens, + List antibiotics + ) { + } + + public record FarmerOption(String businessKey, String name, String email) { + } + + public record MedicationOption(String businessKey, String name, MedicationCategory category) { + } + + public record PathogenOption(String businessKey, String code, String name, PathogenKind kind) { + } + + public record AntibioticOption(String businessKey, String code, String name) { + } + + public record UserOption( + String id, + String code, + String displayName, + String companyName, + String address, + String email, + String portalLogin, + UserRole role + ) { + } + + public record FarmerRow( + String id, + String businessKey, + String name, + String email, + boolean active, + LocalDateTime updatedAt + ) { + } + + public record FarmerMutation(String id, String name, String email, boolean active) { + } + + public record MedicationRow( + String id, + String businessKey, + String name, + MedicationCategory category, + boolean active, + LocalDateTime updatedAt + ) { + } + + public record MedicationMutation(String id, String name, MedicationCategory category, boolean active) { + } + + public record PathogenRow( + String id, + String businessKey, + String code, + String name, + PathogenKind kind, + boolean active, + LocalDateTime updatedAt + ) { + } + + public record PathogenMutation(String id, String code, String name, PathogenKind kind, boolean active) { + } + + public record AntibioticRow( + String id, + String businessKey, + String code, + String name, + boolean active, + LocalDateTime updatedAt + ) { + } + + public record AntibioticMutation(String id, String code, String name, boolean active) { + } + + public record UserRow( + String id, + String code, + String displayName, + String companyName, + String address, + String email, + String portalLogin, + boolean active, + UserRole role, + LocalDateTime updatedAt + ) { + } + + public record UserMutation( + String id, + String code, + String displayName, + String companyName, + String address, + String email, + String portalLogin, + String password, + boolean active, + UserRole role + ) { + } + + public record RegistrationMutation( + String companyName, + String address, + String email, + String password + ) { + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/DemoDataInitializer.java b/backend/src/main/java/de/svencarstensen/muh/service/DemoDataInitializer.java new file mode 100644 index 0000000..c446b62 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/DemoDataInitializer.java @@ -0,0 +1,76 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.AntibioticCatalogItem; +import de.svencarstensen.muh.domain.Farmer; +import de.svencarstensen.muh.domain.MedicationCatalogItem; +import de.svencarstensen.muh.domain.MedicationCategory; +import de.svencarstensen.muh.domain.PathogenCatalogItem; +import de.svencarstensen.muh.domain.PathogenKind; +import de.svencarstensen.muh.repository.AntibioticCatalogRepository; +import de.svencarstensen.muh.repository.FarmerRepository; +import de.svencarstensen.muh.repository.MedicationCatalogRepository; +import de.svencarstensen.muh.repository.PathogenCatalogRepository; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +public class DemoDataInitializer implements ApplicationRunner { + + private final FarmerRepository farmerRepository; + private final MedicationCatalogRepository medicationRepository; + private final PathogenCatalogRepository pathogenRepository; + private final AntibioticCatalogRepository antibioticRepository; + private final CatalogService catalogService; + + public DemoDataInitializer( + FarmerRepository farmerRepository, + MedicationCatalogRepository medicationRepository, + PathogenCatalogRepository pathogenRepository, + AntibioticCatalogRepository antibioticRepository, + CatalogService catalogService + ) { + this.farmerRepository = farmerRepository; + this.medicationRepository = medicationRepository; + this.pathogenRepository = pathogenRepository; + this.antibioticRepository = antibioticRepository; + this.catalogService = catalogService; + } + + @Override + public void run(ApplicationArguments args) { + LocalDateTime now = LocalDateTime.now(); + + if (farmerRepository.count() == 0) { + farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Hof Hansen", "hansen@example.com", true, null, now, now)); + farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Agrar Lindenblick", "lindenblick@example.com", true, null, now, now)); + farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Gut Westerkamp", "westerkamp@example.com", true, null, now, now)); + } + + if (medicationRepository.count() == 0) { + medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Mastijet", MedicationCategory.IN_UDDER, true, null, now, now)); + medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Metacam", MedicationCategory.SYSTEMIC_PAIN, true, null, now, now)); + medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Cobactan", MedicationCategory.SYSTEMIC_ANTIBIOTIC, true, null, now, now)); + medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Orbeseal", MedicationCategory.DRY_SEALER, true, null, now, now)); + medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Nafpenzal", MedicationCategory.DRY_ANTIBIOTIC, true, null, now, now)); + } + + if (pathogenRepository.count() == 0) { + pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "SAU", "Staph. aureus", PathogenKind.BACTERIAL, true, null, now, now)); + pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "ECO", "E. coli", PathogenKind.BACTERIAL, true, null, now, now)); + pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "NG", "Kein Wachstum", PathogenKind.NO_GROWTH, true, null, now, now)); + pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "VER", "Verunreinigt", PathogenKind.CONTAMINATED, true, null, now, now)); + } + + if (antibioticRepository.count() == 0) { + antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "PEN", "Penicillin", true, null, now, now)); + antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "CEF", "Cefalexin", true, null, now, now)); + antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "ENR", "Enrofloxacin", true, null, now, now)); + } + + catalogService.ensureDefaultUsers(); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java b/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java new file mode 100644 index 0000000..0499bff --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java @@ -0,0 +1,127 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.Sample; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +@Service +public class PortalService { + + private final SampleService sampleService; + private final ReportService reportService; + private final CatalogService catalogService; + + public PortalService(SampleService sampleService, ReportService reportService, CatalogService catalogService) { + this.sampleService = sampleService; + this.reportService = reportService; + this.catalogService = catalogService; + } + + public PortalSnapshot snapshot(String farmerBusinessKey, String farmerQuery, String cowQuery, Long sampleNumber, LocalDate date) { + List matchingFarmers = catalogService.activeCatalogSummary().farmers().stream() + .filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT))) + .toList(); + + List sampleRows; + if (sampleNumber != null) { + sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(sampleNumber))); + } else if (farmerBusinessKey != null && !farmerBusinessKey.isBlank()) { + sampleRows = sampleService.samplesByFarmerBusinessKey(farmerBusinessKey).stream() + .filter(sample -> cowQuery == null || cowQuery.isBlank() || cowMatches(sample, cowQuery)) + .map(this::toPortalRow) + .sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed()) + .toList(); + } else if (date != null) { + sampleRows = sampleService.samplesByDate(date).stream() + .map(this::toPortalRow) + .sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .toList(); + } else { + sampleRows = sampleService.completedSamples().stream() + .limit(25) + .map(this::toPortalRow) + .toList(); + } + + return new PortalSnapshot( + matchingFarmers, + sampleRows, + reportService.reportCandidates(), + catalogService.listUsers() + ); + } + + private boolean cowMatches(Sample sample, String cowQuery) { + String query = cowQuery.toLowerCase(Locale.ROOT); + return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query)) + || (sample.cowName() != null && sample.cowName().toLowerCase(Locale.ROOT).contains(query)); + } + + private PortalSampleRow toPortalRow(Sample sample) { + return new PortalSampleRow( + sample.id(), + sample.sampleNumber(), + sample.createdAt(), + sample.completedAt(), + sample.farmerBusinessKey(), + sample.farmerName(), + sample.farmerEmail(), + sample.cowNumber(), + sample.cowName(), + sample.sampleKind().name(), + sample.therapyRecommendation() == null ? null : sample.therapyRecommendation().internalNote(), + sample.currentStep() == de.svencarstensen.muh.domain.SampleWorkflowStep.COMPLETED, + sample.reportSent(), + sample.reportBlocked() + ); + } + + private PortalSampleRow toPortalRow(SampleService.SampleDetail sample) { + return new PortalSampleRow( + sample.id(), + sample.sampleNumber(), + sample.createdAt(), + sample.completedAt(), + sample.farmerBusinessKey(), + sample.farmerName(), + sample.farmerEmail(), + sample.cowNumber(), + sample.cowName(), + sample.sampleKind().name(), + sample.therapy() == null ? null : sample.therapy().internalNote(), + sample.completed(), + sample.reportSent(), + sample.reportBlocked() + ); + } + + public record PortalSnapshot( + List farmers, + List samples, + List reportCandidates, + List users + ) { + } + + public record PortalSampleRow( + String sampleId, + long sampleNumber, + java.time.LocalDateTime createdAt, + java.time.LocalDateTime completedAt, + String farmerBusinessKey, + String farmerName, + String farmerEmail, + String cowNumber, + String cowName, + String sampleKindLabel, + String internalNote, + boolean completed, + boolean reportSent, + boolean reportBlocked + ) { + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java b/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java new file mode 100644 index 0000000..df515e6 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java @@ -0,0 +1,210 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.Sample; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.mail.internet.MimeMessage; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Service +public class ReportService { + + private final SampleService sampleService; + private final ObjectProvider mailSenderProvider; + private final boolean mailEnabled; + private final String mailFrom; + + public ReportService( + SampleService sampleService, + ObjectProvider mailSenderProvider, + @Value("${muh.mail.enabled:false}") boolean mailEnabled, + @Value("${muh.mail.from:no-reply@muh.local}") String mailFrom + ) { + this.sampleService = sampleService; + this.mailSenderProvider = mailSenderProvider; + this.mailEnabled = mailEnabled; + this.mailFrom = mailFrom; + } + + public List reportCandidates() { + return sampleService.completedSamples().stream() + .filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank()) + .map(this::toCandidate) + .toList(); + } + + public DispatchResult sendReports(List sampleIds) { + List sent = new ArrayList<>(); + List skipped = new ArrayList<>(); + + for (String sampleId : sampleIds) { + Sample sample = sampleService.loadSampleEntity(sampleId); + if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) { + skipped.add(toCandidate(sample)); + continue; + } + + byte[] pdf = buildPdf(sample); + if (mailEnabled && mailSenderProvider.getIfAvailable() != null) { + sendMail(sample, pdf); + } + Sample updated = sampleService.markReportSent(sample.id(), LocalDateTime.now()); + sent.add(toCandidate(updated)); + } + return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null); + } + + public byte[] reportPdf(String sampleId) { + return buildPdf(sampleService.loadSampleEntity(sampleId)); + } + + public SampleService.SampleDetail toggleReportBlocked(String sampleId, boolean blocked) { + return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id()); + } + + private void sendMail(Sample sample, byte[] pdf) { + try { + JavaMailSender sender = mailSenderProvider.getIfAvailable(); + if (sender == null) { + return; + } + MimeMessage message = sender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()); + helper.setFrom(requireText(mailFrom, "Absender fehlt")); + helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt")); + helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber()); + helper.setText("Im Anhang befindet sich der Bericht zur Probe " + sample.sampleNumber() + ".", false); + helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf))); + sender.send(message); + } catch (Exception exception) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Mailversand fehlgeschlagen", exception); + } + } + + private ReportCandidate toCandidate(Sample sample) { + return new ReportCandidate( + sample.id(), + sample.sampleNumber(), + sample.farmerName(), + sample.farmerEmail(), + sample.cowName() == null ? sample.cowNumber() : sample.cowNumber() + " / " + sample.cowName(), + sample.completedAt(), + sample.reportSent(), + sample.reportBlocked() + ); + } + + private byte[] buildPdf(Sample sample) { + List lines = new ArrayList<>(); + lines.add("MUH-Bericht"); + lines.add("Probe: " + sample.sampleNumber()); + lines.add("Landwirt: " + sample.farmerName()); + lines.add("Kuh: " + sample.cowNumber() + (sample.cowName() == null ? "" : " / " + sample.cowName())); + lines.add("Typ: " + sample.sampleKind()); + lines.add("Abgeschlossen: " + (sample.completedAt() == null ? "-" : sample.completedAt().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")))); + lines.add(""); + lines.add("Anamnese"); + sample.quarters().forEach(quarter -> lines.add( + quarter.quarterKey().label() + ": " + + (quarter.effectivePathogenLabel() == null ? "-" : quarter.effectivePathogenLabel()) + + (quarter.flagged() ? " [auffaellig]" : "") + )); + lines.add(""); + lines.add("Therapie"); + if (sample.therapyRecommendation() != null) { + lines.add("Hinweis Landwirt: " + defaultText(sample.therapyRecommendation().farmerNote())); + lines.add("Interne Bemerkung: " + defaultText(sample.therapyRecommendation().internalNote())); + } + + return renderSimplePdf(lines); + } + + private String defaultText(String value) { + return value == null || value.isBlank() ? "-" : value; + } + + private @NonNull String requireText(String value, String message) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return Objects.requireNonNull(value); + } + + private byte[] renderSimplePdf(List lines) { + StringBuilder content = new StringBuilder(); + content.append("BT\n/F1 12 Tf\n50 790 Td\n"); + boolean first = true; + for (String line : lines) { + if (!first) { + content.append("0 -18 Td\n"); + } + content.append("(").append(escapePdf(line)).append(") Tj\n"); + first = false; + } + content.append("ET"); + + String stream = content.toString(); + List objects = List.of( + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Length " + stream.getBytes(StandardCharsets.UTF_8).length + " >>\nstream\n" + stream + "\nendstream" + ); + + StringBuilder pdf = new StringBuilder("%PDF-1.4\n"); + List offsets = new ArrayList<>(); + for (int index = 0; index < objects.size(); index++) { + offsets.add(pdf.toString().getBytes(StandardCharsets.UTF_8).length); + pdf.append(index + 1).append(" 0 obj\n").append(objects.get(index)).append("\nendobj\n"); + } + int xrefOffset = pdf.toString().getBytes(StandardCharsets.UTF_8).length; + pdf.append("xref\n0 ").append(objects.size() + 1).append("\n"); + pdf.append("0000000000 65535 f \n"); + for (Integer offset : offsets) { + pdf.append(String.format("%010d 00000 n %n", offset)); + } + pdf.append("trailer\n<< /Size ").append(objects.size() + 1).append(" /Root 1 0 R >>\n"); + pdf.append("startxref\n").append(xrefOffset).append("\n%%EOF"); + return pdf.toString().getBytes(StandardCharsets.UTF_8); + } + + private String escapePdf(String value) { + return value + .replace("\\", "\\\\") + .replace("(", "\\(") + .replace(")", "\\)"); + } + + public record ReportCandidate( + String sampleId, + long sampleNumber, + String farmerName, + String farmerEmail, + String cowLabel, + LocalDateTime completedAt, + boolean reportSent, + boolean reportBlocked + ) { + } + + public record DispatchResult( + List sent, + List skipped, + boolean mailDeliveryActive + ) { + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java b/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java new file mode 100644 index 0000000..6f749e7 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/SampleService.java @@ -0,0 +1,769 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.AntibiogramEntry; +import de.svencarstensen.muh.domain.PathogenCatalogItem; +import de.svencarstensen.muh.domain.PathogenKind; +import de.svencarstensen.muh.domain.QuarterAntibiogram; +import de.svencarstensen.muh.domain.QuarterFinding; +import de.svencarstensen.muh.domain.QuarterKey; +import de.svencarstensen.muh.domain.Sample; +import de.svencarstensen.muh.domain.SampleKind; +import de.svencarstensen.muh.domain.SampleWorkflowStep; +import de.svencarstensen.muh.domain.SamplingMode; +import de.svencarstensen.muh.domain.SensitivityResult; +import de.svencarstensen.muh.domain.TherapyRecommendation; +import de.svencarstensen.muh.repository.SampleRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class SampleService { + + private final SampleRepository sampleRepository; + private final CatalogService catalogService; + + public SampleService(SampleRepository sampleRepository, CatalogService catalogService) { + this.sampleRepository = sampleRepository; + this.catalogService = catalogService; + } + + public DashboardOverview dashboardOverview() { + List recent = sampleRepository.findTop12ByOrderByUpdatedAtDesc().stream() + .map(this::toSummary) + .toList(); + long openCount = sampleRepository.findAll().stream().filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED).count(); + LocalDate today = LocalDate.now(); + long completedToday = sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc( + today.atStartOfDay(), + today.plusDays(1).atStartOfDay() + ).size(); + return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent); + } + + public LookupResult lookup(long sampleNumber) { + return sampleRepository.findBySampleNumber(sampleNumber) + .map(sample -> new LookupResult( + true, + "Probe gefunden", + sample.id(), + sample.currentStep(), + SampleWorkflowRules.routeSegment(sample.currentStep()) + )) + .orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null)); + } + + public SampleDetail getSample(String id) { + return toDetail(loadSample(id)); + } + + public SampleDetail getSampleByNumber(long sampleNumber) { + return sampleRepository.findBySampleNumber(sampleNumber) + .map(this::toDetail) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden")); + } + + public SampleDetail createSample(RegistrationRequest request) { + LocalDateTime now = LocalDateTime.now(); + CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() + .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); + + Sample sample = new Sample( + null, + nextSampleNumber(), + farmer.businessKey(), + farmer.name(), + farmer.email(), + request.cowNumber().trim(), + blankToNull(request.cowName()), + request.sampleKind(), + request.samplingMode(), + SampleWorkflowStep.ANAMNESIS, + buildQuarters(request), + List.of(), + null, + false, + false, + null, + now, + now, + null, + request.userCode(), + request.userDisplayName() + ); + + return toDetail(sampleRepository.save(sample)); + } + + public SampleDetail saveRegistration(String id, RegistrationRequest request) { + Sample existing = loadSample(id); + if (!SampleWorkflowRules.canEditRegistration(existing)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden"); + } + + CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() + .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); + + Sample saved = sampleRepository.save(new Sample( + existing.id(), + existing.sampleNumber(), + farmer.businessKey(), + farmer.name(), + farmer.email(), + request.cowNumber().trim(), + blankToNull(request.cowName()), + request.sampleKind(), + request.samplingMode(), + SampleWorkflowStep.ANAMNESIS, + buildQuarters(request), + List.of(), + existing.therapyRecommendation(), + existing.reportSent(), + existing.reportBlocked(), + existing.reportSentAt(), + existing.createdAt(), + LocalDateTime.now(), + existing.completedAt(), + existing.createdByUserCode(), + existing.createdByDisplayName() + )); + + return toDetail(saved); + } + + public SampleDetail saveAnamnesis(String id, AnamnesisRequest request) { + Sample existing = loadSample(id); + if (!SampleWorkflowRules.canEditAnamnesis(existing)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden"); + } + + Map current = new HashMap<>(); + for (QuarterFinding quarter : existing.quarters()) { + current.put(quarter.quarterKey(), quarter); + } + + Map pathogens = catalogService.activePathogensByBusinessKey(); + List updatedQuarters = new ArrayList<>(); + for (AnamnesisQuarterRequest quarterRequest : request.quarters()) { + QuarterFinding base = current.get(quarterRequest.quarterKey()); + if (base == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Entnahmestelle unbekannt"); + } + + PathogenCatalogItem catalogItem = quarterRequest.pathogenBusinessKey() == null + ? null + : pathogens.get(quarterRequest.pathogenBusinessKey()); + + String customPathogen = blankToNull(quarterRequest.customPathogenName()); + PathogenKind pathogenKind = catalogItem != null ? catalogItem.kind() : (customPathogen == null ? null : PathogenKind.OTHER); + updatedQuarters.add(new QuarterFinding( + base.quarterKey(), + base.flagged(), + catalogItem != null ? catalogItem.businessKey() : null, + catalogItem != null ? catalogItem.code() : null, + catalogItem != null ? catalogItem.name() : null, + pathogenKind, + customPathogen, + quarterRequest.cellCount() + )); + } + + updatedQuarters.sort(Comparator.comparingInt(this::quarterSort)); + SampleWorkflowStep nextStep = SampleWorkflowRules.nextStepAfterAnamnesis(updatedQuarters); + Sample saved = sampleRepository.save(new Sample( + existing.id(), + existing.sampleNumber(), + existing.farmerBusinessKey(), + existing.farmerName(), + existing.farmerEmail(), + existing.cowNumber(), + existing.cowName(), + existing.sampleKind(), + existing.samplingMode(), + nextStep, + updatedQuarters, + List.of(), + existing.therapyRecommendation(), + existing.reportSent(), + existing.reportBlocked(), + existing.reportSentAt(), + existing.createdAt(), + LocalDateTime.now(), + existing.completedAt(), + existing.createdByUserCode(), + existing.createdByDisplayName() + )); + return toDetail(saved); + } + + public SampleDetail saveAntibiogram(String id, AntibiogramRequest request) { + Sample existing = loadSample(id); + if (!SampleWorkflowRules.canEditAntibiogram(existing)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden"); + } + + Map antibiotics = catalogService.activeAntibioticsByBusinessKey(); + Map groups = new HashMap<>(); + Map quartersByKey = existing.quarters().stream() + .collect(java.util.stream.Collectors.toMap(QuarterFinding::quarterKey, quarter -> quarter)); + + for (AntibiogramGroupRequest groupRequest : request.groups()) { + QuarterFinding referenceQuarter = quartersByKey.get(groupRequest.referenceQuarter()); + if (referenceQuarter == null || !referenceQuarter.requiresAntibiogram()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültige Referenz für Antibiogramm"); + } + + List entries = groupRequest.entries().stream() + .map(entry -> { + de.svencarstensen.muh.domain.AntibioticCatalogItem catalogItem = antibiotics.get(entry.antibioticBusinessKey()); + if (catalogItem == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiotikum unbekannt"); + } + return new AntibiogramEntry( + catalogItem.businessKey(), + catalogItem.code(), + catalogItem.name(), + entry.result() + ); + }) + .toList(); + + for (QuarterFinding quarter : existing.quarters()) { + if (!quarter.requiresAntibiogram()) { + continue; + } + if (Objects.equals( + SampleWorkflowRules.pathogenIdentity(referenceQuarter), + SampleWorkflowRules.pathogenIdentity(quarter) + )) { + groups.put(quarter.quarterKey(), new QuarterAntibiogram( + quarter.quarterKey(), + quarter.pathogenBusinessKey(), + quarter.effectivePathogenLabel(), + referenceQuarter.quarterKey().equals(quarter.quarterKey()) ? null : referenceQuarter.quarterKey(), + entries + )); + } + } + } + + List savedGroups = existing.quarters().stream() + .filter(QuarterFinding::requiresAntibiogram) + .map(quarter -> groups.getOrDefault(quarter.quarterKey(), new QuarterAntibiogram( + quarter.quarterKey(), + quarter.pathogenBusinessKey(), + quarter.effectivePathogenLabel(), + SampleWorkflowRules.referenceQuarterForPathogen(existing.quarters(), quarter).equals(quarter.quarterKey()) + ? null + : SampleWorkflowRules.referenceQuarterForPathogen(existing.quarters(), quarter), + List.of() + ))) + .toList(); + + Sample saved = sampleRepository.save(new Sample( + existing.id(), + existing.sampleNumber(), + existing.farmerBusinessKey(), + existing.farmerName(), + existing.farmerEmail(), + existing.cowNumber(), + existing.cowName(), + existing.sampleKind(), + existing.samplingMode(), + SampleWorkflowStep.THERAPY, + existing.quarters(), + savedGroups, + existing.therapyRecommendation(), + existing.reportSent(), + existing.reportBlocked(), + existing.reportSentAt(), + existing.createdAt(), + LocalDateTime.now(), + existing.completedAt(), + existing.createdByUserCode(), + existing.createdByDisplayName() + )); + return toDetail(saved); + } + + public SampleDetail saveTherapy(String id, TherapyRequest request) { + Sample existing = loadSample(id); + if (existing.currentStep() == SampleWorkflowStep.COMPLETED) { + TherapyRecommendation previous = existing.therapyRecommendation(); + TherapyRecommendation updated = previous == null + ? new TherapyRecommendation(false, false, List.of(), List.of(), null, List.of(), List.of(), null, List.of(), List.of(), List.of(), List.of(), null, blankToNull(request.internalNote())) + : new TherapyRecommendation( + previous.continueStarted(), + previous.switchTherapy(), + previous.inUdderMedicationKeys(), + previous.inUdderMedicationNames(), + previous.inUdderOther(), + previous.systemicMedicationKeys(), + previous.systemicMedicationNames(), + previous.systemicOther(), + previous.drySealerKeys(), + previous.drySealerNames(), + previous.dryAntibioticKeys(), + previous.dryAntibioticNames(), + previous.farmerNote(), + blankToNull(request.internalNote()) + ); + return toDetail(sampleRepository.save(new Sample( + existing.id(), + existing.sampleNumber(), + existing.farmerBusinessKey(), + existing.farmerName(), + existing.farmerEmail(), + existing.cowNumber(), + existing.cowName(), + existing.sampleKind(), + existing.samplingMode(), + SampleWorkflowStep.COMPLETED, + existing.quarters(), + existing.antibiograms(), + updated, + existing.reportSent(), + existing.reportBlocked(), + existing.reportSentAt(), + existing.createdAt(), + LocalDateTime.now(), + existing.completedAt(), + existing.createdByUserCode(), + existing.createdByDisplayName() + ))); + } + + if (!SampleWorkflowRules.canEditTherapy(existing)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Therapie kann nicht bearbeitet werden"); + } + + Map medications = catalogService.activeMedicationsByBusinessKey(); + TherapyRecommendation therapy = new TherapyRecommendation( + request.continueStarted(), + request.switchTherapy(), + request.inUdderMedicationKeys(), + resolveMedicationNames(request.inUdderMedicationKeys(), medications), + blankToNull(request.inUdderOther()), + request.systemicMedicationKeys(), + resolveMedicationNames(request.systemicMedicationKeys(), medications), + blankToNull(request.systemicOther()), + request.drySealerKeys(), + resolveMedicationNames(request.drySealerKeys(), medications), + request.dryAntibioticKeys(), + resolveMedicationNames(request.dryAntibioticKeys(), medications), + blankToNull(request.farmerNote()), + blankToNull(request.internalNote()) + ); + + Sample saved = sampleRepository.save(new Sample( + existing.id(), + existing.sampleNumber(), + existing.farmerBusinessKey(), + existing.farmerName(), + existing.farmerEmail(), + existing.cowNumber(), + existing.cowName(), + existing.sampleKind(), + existing.samplingMode(), + SampleWorkflowStep.COMPLETED, + existing.quarters(), + existing.antibiograms(), + therapy, + existing.reportSent(), + existing.reportBlocked(), + existing.reportSentAt(), + existing.createdAt(), + LocalDateTime.now(), + LocalDateTime.now(), + existing.createdByUserCode(), + existing.createdByDisplayName() + )); + return toDetail(saved); + } + + public Sample markReportSent(String id, LocalDateTime sentAt) { + Sample existing = loadSample(id); + return sampleRepository.save(new Sample( + existing.id(), + existing.sampleNumber(), + existing.farmerBusinessKey(), + existing.farmerName(), + existing.farmerEmail(), + existing.cowNumber(), + existing.cowName(), + existing.sampleKind(), + existing.samplingMode(), + existing.currentStep(), + existing.quarters(), + existing.antibiograms(), + existing.therapyRecommendation(), + true, + existing.reportBlocked(), + sentAt, + existing.createdAt(), + LocalDateTime.now(), + existing.completedAt(), + existing.createdByUserCode(), + existing.createdByDisplayName() + )); + } + + public Sample toggleReportBlocked(String id, boolean blocked) { + Sample existing = loadSample(id); + return sampleRepository.save(new Sample( + existing.id(), + existing.sampleNumber(), + existing.farmerBusinessKey(), + existing.farmerName(), + existing.farmerEmail(), + existing.cowNumber(), + existing.cowName(), + existing.sampleKind(), + existing.samplingMode(), + existing.currentStep(), + existing.quarters(), + existing.antibiograms(), + existing.therapyRecommendation(), + existing.reportSent(), + blocked, + existing.reportSentAt(), + existing.createdAt(), + LocalDateTime.now(), + existing.completedAt(), + existing.createdByUserCode(), + existing.createdByDisplayName() + )); + } + + public List completedSamples() { + return sampleRepository.findByCompletedAtNotNullOrderByCompletedAtDesc(); + } + + public List samplesByFarmerBusinessKey(String businessKey) { + return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey); + } + + public List samplesByDate(LocalDate date) { + return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); + } + + public long nextSampleNumber() { + return sampleRepository.findTopByOrderBySampleNumberDesc() + .map(sample -> sample.sampleNumber() + 1) + .orElse(100001L); + } + + public Sample loadSampleEntity(String id) { + return loadSample(id); + } + + private Sample loadSample(String id) { + return sampleRepository.findById(Objects.requireNonNull(id)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden")); + } + + private SampleSummary toSummary(Sample sample) { + return new SampleSummary( + sample.id(), + sample.sampleNumber(), + sample.farmerName(), + sample.cowName() == null ? sample.cowNumber() : sample.cowNumber() + " / " + sample.cowName(), + sample.sampleKind(), + sample.currentStep(), + sample.updatedAt(), + sample.reportSent(), + sample.reportBlocked() + ); + } + + private SampleDetail toDetail(Sample sample) { + List quarters = sample.quarters().stream() + .map(quarter -> new QuarterView( + quarter.quarterKey(), + quarter.quarterKey().label(), + quarter.flagged(), + quarter.pathogenBusinessKey(), + quarter.pathogenCode(), + quarter.pathogenName(), + quarter.pathogenKind(), + quarter.customPathogenName(), + quarter.cellCount(), + quarter.requiresAntibiogram() + )) + .sorted(Comparator.comparingInt(quarter -> quarterSort(quarter.quarterKey()))) + .toList(); + + List antibiograms = sample.antibiograms().stream() + .map(group -> new AntibiogramView( + group.quarterKey(), + group.pathogenName(), + group.inheritedFromQuarter(), + group.entries().stream() + .map(entry -> new AntibiogramEntryView(entry.antibioticBusinessKey(), entry.antibioticCode(), entry.antibioticName(), entry.result())) + .toList() + )) + .toList(); + + return new SampleDetail( + sample.id(), + sample.sampleNumber(), + sample.farmerBusinessKey(), + sample.farmerName(), + sample.farmerEmail(), + sample.cowNumber(), + sample.cowName(), + sample.sampleKind(), + sample.samplingMode(), + sample.currentStep(), + sample.createdAt(), + sample.updatedAt(), + sample.completedAt(), + sample.createdByUserCode(), + sample.createdByDisplayName(), + sample.reportSent(), + sample.reportBlocked(), + sample.reportSentAt(), + SampleWorkflowRules.routeSegment(sample.currentStep()), + quarters, + antibiograms, + toTherapyView(sample.therapyRecommendation()), + SampleWorkflowRules.antibiogramTargets(sample.quarters()), + SampleWorkflowRules.canEditRegistration(sample), + SampleWorkflowRules.canEditAnamnesis(sample), + SampleWorkflowRules.canEditAntibiogram(sample), + SampleWorkflowRules.canEditTherapy(sample), + sample.currentStep() == SampleWorkflowStep.COMPLETED + ); + } + + private TherapyView toTherapyView(TherapyRecommendation therapy) { + if (therapy == null) { + return null; + } + return new TherapyView( + therapy.continueStarted(), + therapy.switchTherapy(), + therapy.inUdderMedicationKeys(), + therapy.inUdderMedicationNames(), + therapy.inUdderOther(), + therapy.systemicMedicationKeys(), + therapy.systemicMedicationNames(), + therapy.systemicOther(), + therapy.drySealerKeys(), + therapy.drySealerNames(), + therapy.dryAntibioticKeys(), + therapy.dryAntibioticNames(), + therapy.farmerNote(), + therapy.internalNote() + ); + } + + private List resolveMedicationNames(List keys, Map medications) { + return keys == null ? List.of() : keys.stream() + .map(medications::get) + .filter(Objects::nonNull) + .map(de.svencarstensen.muh.domain.MedicationCatalogItem::name) + .toList(); + } + + private List buildQuarters(RegistrationRequest request) { + return switch (request.samplingMode()) { + case SINGLE_SITE -> List.of(new QuarterFinding(QuarterKey.SINGLE, false, null, null, null, null, null, null)); + case UNKNOWN_SITE -> List.of(new QuarterFinding(QuarterKey.UNKNOWN, false, null, null, null, null, null, null)); + case FOUR_QUARTER -> List.of( + new QuarterFinding(QuarterKey.LEFT_FRONT, request.flaggedQuarters().contains(QuarterKey.LEFT_FRONT), null, null, null, null, null, null), + new QuarterFinding(QuarterKey.RIGHT_FRONT, request.flaggedQuarters().contains(QuarterKey.RIGHT_FRONT), null, null, null, null, null, null), + new QuarterFinding(QuarterKey.LEFT_REAR, request.flaggedQuarters().contains(QuarterKey.LEFT_REAR), null, null, null, null, null, null), + new QuarterFinding(QuarterKey.RIGHT_REAR, request.flaggedQuarters().contains(QuarterKey.RIGHT_REAR), null, null, null, null, null, null) + ); + }; + } + + private int quarterSort(QuarterFinding finding) { + return quarterSort(finding.quarterKey()); + } + + private int quarterSort(QuarterKey key) { + return switch (key) { + case SINGLE -> 0; + case UNKNOWN -> 1; + case LEFT_FRONT -> 2; + case RIGHT_FRONT -> 3; + case LEFT_REAR -> 4; + case RIGHT_REAR -> 5; + }; + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + public record DashboardOverview( + long nextSampleNumber, + long openSamples, + long completedToday, + List recentSamples + ) { + } + + public record SampleSummary( + String id, + long sampleNumber, + String farmerName, + String cowLabel, + SampleKind sampleKind, + SampleWorkflowStep currentStep, + LocalDateTime updatedAt, + boolean reportSent, + boolean reportBlocked + ) { + } + + public record LookupResult( + boolean found, + String message, + String sampleId, + SampleWorkflowStep step, + String routeSegment + ) { + } + + public record QuarterView( + QuarterKey quarterKey, + String label, + boolean flagged, + String pathogenBusinessKey, + String pathogenCode, + String pathogenName, + PathogenKind pathogenKind, + String customPathogenName, + Integer cellCount, + boolean requiresAntibiogram + ) { + } + + public record AntibiogramEntryView( + String antibioticBusinessKey, + String antibioticCode, + String antibioticName, + SensitivityResult result + ) { + } + + public record AntibiogramView( + QuarterKey quarterKey, + String pathogenName, + QuarterKey inheritedFromQuarter, + List entries + ) { + } + + public record TherapyView( + boolean continueStarted, + boolean switchTherapy, + List inUdderMedicationKeys, + List inUdderMedicationNames, + String inUdderOther, + List systemicMedicationKeys, + List systemicMedicationNames, + String systemicOther, + List drySealerKeys, + List drySealerNames, + List dryAntibioticKeys, + List dryAntibioticNames, + String farmerNote, + String internalNote + ) { + } + + public record SampleDetail( + String id, + long sampleNumber, + String farmerBusinessKey, + String farmerName, + String farmerEmail, + String cowNumber, + String cowName, + SampleKind sampleKind, + SamplingMode samplingMode, + SampleWorkflowStep currentStep, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime completedAt, + String createdByUserCode, + String createdByDisplayName, + boolean reportSent, + boolean reportBlocked, + LocalDateTime reportSentAt, + String routeSegment, + List quarters, + List antibiograms, + TherapyView therapy, + List antibiogramTargets, + boolean registrationEditable, + boolean anamnesisEditable, + boolean antibiogramEditable, + boolean therapyEditable, + boolean completed + ) { + } + + public record RegistrationRequest( + String farmerBusinessKey, + String cowNumber, + String cowName, + SampleKind sampleKind, + SamplingMode samplingMode, + List flaggedQuarters, + String userCode, + String userDisplayName + ) { + } + + public record AnamnesisQuarterRequest( + QuarterKey quarterKey, + String pathogenBusinessKey, + String customPathogenName, + Integer cellCount + ) { + } + + public record AnamnesisRequest(List quarters) { + } + + public record AntibiogramLineRequest(String antibioticBusinessKey, SensitivityResult result) { + } + + public record AntibiogramGroupRequest(QuarterKey referenceQuarter, List entries) { + } + + public record AntibiogramRequest(List groups) { + } + + public record TherapyRequest( + boolean continueStarted, + boolean switchTherapy, + List inUdderMedicationKeys, + String inUdderOther, + List systemicMedicationKeys, + String systemicOther, + List drySealerKeys, + List dryAntibioticKeys, + String farmerNote, + String internalNote + ) { + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/SampleWorkflowRules.java b/backend/src/main/java/de/svencarstensen/muh/service/SampleWorkflowRules.java new file mode 100644 index 0000000..9a524be --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/SampleWorkflowRules.java @@ -0,0 +1,85 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.PathogenKind; +import de.svencarstensen.muh.domain.QuarterFinding; +import de.svencarstensen.muh.domain.QuarterKey; +import de.svencarstensen.muh.domain.Sample; +import de.svencarstensen.muh.domain.SampleWorkflowStep; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class SampleWorkflowRules { + + private SampleWorkflowRules() { + } + + public static String routeSegment(SampleWorkflowStep step) { + return switch (step) { + case ANAMNESIS -> "anamnesis"; + case ANTIBIOGRAM -> "antibiogram"; + case THERAPY, COMPLETED -> "therapy"; + }; + } + + public static SampleWorkflowStep nextStepAfterAnamnesis(List quarters) { + return antibiogramTargets(quarters).isEmpty() + ? SampleWorkflowStep.THERAPY + : SampleWorkflowStep.ANTIBIOGRAM; + } + + public static boolean canEditRegistration(Sample sample) { + return sample.currentStep() == SampleWorkflowStep.ANAMNESIS; + } + + public static boolean canEditAnamnesis(Sample sample) { + return sample.currentStep() == SampleWorkflowStep.ANAMNESIS + || sample.currentStep() == SampleWorkflowStep.ANTIBIOGRAM; + } + + public static boolean canEditAntibiogram(Sample sample) { + return sample.currentStep() == SampleWorkflowStep.ANTIBIOGRAM + || sample.currentStep() == SampleWorkflowStep.THERAPY; + } + + public static boolean canEditTherapy(Sample sample) { + return sample.currentStep() == SampleWorkflowStep.THERAPY; + } + + public static List antibiogramTargets(List quarters) { + Map firstByPathogen = new HashMap<>(); + List targets = new ArrayList<>(); + + for (QuarterFinding quarter : quarters) { + if (quarter.pathogenKind() != PathogenKind.BACTERIAL) { + continue; + } + String identity = pathogenIdentity(quarter); + if (firstByPathogen.putIfAbsent(identity, quarter.quarterKey()) == null) { + targets.add(quarter.quarterKey()); + } + } + return targets; + } + + public static QuarterKey referenceQuarterForPathogen(List quarters, QuarterFinding candidate) { + String identity = pathogenIdentity(candidate); + for (QuarterFinding quarter : quarters) { + if (quarter.pathogenKind() == PathogenKind.BACTERIAL + && Objects.equals(identity, pathogenIdentity(quarter))) { + return quarter.quarterKey(); + } + } + return candidate.quarterKey(); + } + + public static String pathogenIdentity(QuarterFinding quarter) { + if (quarter.pathogenBusinessKey() != null && !quarter.pathogenBusinessKey().isBlank()) { + return quarter.pathogenBusinessKey(); + } + return quarter.effectivePathogenLabel(); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java b/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java new file mode 100644 index 0000000..164b8d6 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java @@ -0,0 +1,76 @@ +package de.svencarstensen.muh.web; + +import de.svencarstensen.muh.service.CatalogService; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api") +public class CatalogController { + + private final CatalogService catalogService; + + public CatalogController(CatalogService catalogService) { + this.catalogService = catalogService; + } + + @GetMapping("/catalogs/summary") + public CatalogService.ActiveCatalogSummary catalogSummary() { + return catalogService.activeCatalogSummary(); + } + + @GetMapping("/admin") + public CatalogService.AdministrationOverview administrationOverview() { + return catalogService.administrationOverview(); + } + + @PostMapping("/admin/farmers") + public List saveFarmers(@RequestBody List mutations) { + return catalogService.saveFarmers(mutations); + } + + @PostMapping("/admin/medications") + public List saveMedications(@RequestBody List mutations) { + return catalogService.saveMedications(mutations); + } + + @PostMapping("/admin/pathogens") + public List savePathogens(@RequestBody List mutations) { + return catalogService.savePathogens(mutations); + } + + @PostMapping("/admin/antibiotics") + public List saveAntibiotics(@RequestBody List mutations) { + return catalogService.saveAntibiotics(mutations); + } + + @GetMapping("/portal/users") + public List users() { + return catalogService.listUsers(); + } + + @PostMapping("/portal/users") + public CatalogService.UserRow saveUser(@RequestBody CatalogService.UserMutation mutation) { + return catalogService.createOrUpdateUser(mutation); + } + + @DeleteMapping("/portal/users/{id}") + public void deleteUser(@PathVariable String id) { + catalogService.deleteUser(id); + } + + @PostMapping("/portal/users/{id}/password") + public void changePassword(@PathVariable String id, @RequestBody PasswordChangeRequest request) { + catalogService.changePassword(id, request.password()); + } + + public record PasswordChangeRequest(String password) { + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java new file mode 100644 index 0000000..b31d0f2 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java @@ -0,0 +1,78 @@ +package de.svencarstensen.muh.web; + +import de.svencarstensen.muh.service.PortalService; +import de.svencarstensen.muh.service.ReportService; +import de.svencarstensen.muh.service.SampleService; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; + +@RestController +@RequestMapping("/api/portal") +public class PortalController { + + private final PortalService portalService; + private final ReportService reportService; + + public PortalController(PortalService portalService, ReportService reportService) { + this.portalService = portalService; + this.reportService = reportService; + } + + @GetMapping("/snapshot") + public PortalService.PortalSnapshot snapshot( + @RequestParam(required = false) String farmerBusinessKey, + @RequestParam(required = false) String farmerQuery, + @RequestParam(required = false) String cowQuery, + @RequestParam(required = false) Long sampleNumber, + @RequestParam(required = false) LocalDate date + ) { + return portalService.snapshot(farmerBusinessKey, farmerQuery, cowQuery, sampleNumber, date); + } + + @GetMapping("/reports") + public List reports() { + return reportService.reportCandidates(); + } + + @PostMapping("/reports/send") + public ReportService.DispatchResult send(@RequestBody ReportDispatchRequest request) { + return reportService.sendReports(request.sampleIds()); + } + + @PatchMapping("/reports/{sampleId}/block") + public SampleService.SampleDetail block(@PathVariable String sampleId, @RequestBody BlockRequest request) { + return reportService.toggleReportBlocked(sampleId, request.blocked()); + } + + @GetMapping("/reports/{sampleId}/pdf") + public ResponseEntity pdf(@PathVariable String sampleId) { + byte[] pdf = reportService.reportPdf(sampleId); + return ResponseEntity.ok() + .contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF)) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() + .filename("MUH-Bericht-" + sampleId + ".pdf") + .build() + .toString()) + .body(pdf); + } + + public record ReportDispatchRequest(List sampleIds) { + } + + public record BlockRequest(boolean blocked) { + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/web/SampleController.java b/backend/src/main/java/de/svencarstensen/muh/web/SampleController.java new file mode 100644 index 0000000..6f89bcb --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/web/SampleController.java @@ -0,0 +1,66 @@ +package de.svencarstensen.muh.web; + +import de.svencarstensen.muh.service.SampleService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +public class SampleController { + + private final SampleService sampleService; + + public SampleController(SampleService sampleService) { + this.sampleService = sampleService; + } + + @GetMapping("/dashboard") + public SampleService.DashboardOverview dashboardOverview() { + return sampleService.dashboardOverview(); + } + + @GetMapping("/dashboard/lookup/{sampleNumber}") + public SampleService.LookupResult lookup(@PathVariable long sampleNumber) { + return sampleService.lookup(sampleNumber); + } + + @GetMapping("/samples/{id}") + public SampleService.SampleDetail sample(@PathVariable String id) { + return sampleService.getSample(id); + } + + @GetMapping("/samples/by-number/{sampleNumber}") + public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) { + return sampleService.getSampleByNumber(sampleNumber); + } + + @PostMapping("/samples") + public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) { + return sampleService.createSample(request); + } + + @PutMapping("/samples/{id}/registration") + public SampleService.SampleDetail saveRegistration(@PathVariable String id, @RequestBody SampleService.RegistrationRequest request) { + return sampleService.saveRegistration(id, request); + } + + @PutMapping("/samples/{id}/anamnesis") + public SampleService.SampleDetail saveAnamnesis(@PathVariable String id, @RequestBody SampleService.AnamnesisRequest request) { + return sampleService.saveAnamnesis(id, request); + } + + @PutMapping("/samples/{id}/antibiogram") + public SampleService.SampleDetail saveAntibiogram(@PathVariable String id, @RequestBody SampleService.AntibiogramRequest request) { + return sampleService.saveAntibiogram(id, request); + } + + @PutMapping("/samples/{id}/therapy") + public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) { + return sampleService.saveTherapy(id, request); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java b/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java new file mode 100644 index 0000000..f2219ed --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java @@ -0,0 +1,61 @@ +package de.svencarstensen.muh.web; + +import de.svencarstensen.muh.service.CatalogService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +@RestController +@RequestMapping("/api/session") +public class SessionController { + + private final CatalogService catalogService; + + public SessionController(CatalogService catalogService) { + this.catalogService = catalogService; + } + + @GetMapping("/users") + public List activeUsers() { + return catalogService.activeCatalogSummary().users(); + } + + @PostMapping("/login") + public CatalogService.UserOption login(@RequestBody LoginRequest request) { + return catalogService.loginByCode(request.code()); + } + + @PostMapping("/password-login") + public CatalogService.UserOption passwordLogin(@RequestBody PasswordLoginRequest request) { + return catalogService.loginWithPassword(request.identifier(), request.password()); + } + + @PostMapping("/register") + public CatalogService.UserOption register(@RequestBody RegistrationRequest request) { + return catalogService.registerCustomer(new CatalogService.RegistrationMutation( + request.companyName(), + request.address(), + request.email(), + request.password() + )); + } + + public record LoginRequest(@NotBlank String code) { + } + + public record PasswordLoginRequest(@NotBlank String identifier, @NotBlank String password) { + } + + public record RegistrationRequest( + @NotBlank String companyName, + @NotBlank String address, + @NotBlank String email, + @NotBlank String password + ) { + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..5af48ce --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,29 @@ +server: + port: 8090 + +spring: + application: + name: muh-backend + data: + mongodb: + uri: mongodb://192.168.180.25:27017/muh + jackson: + time-zone: Europe/Berlin + mail: + host: ${MUH_MAIL_HOST:} + port: ${MUH_MAIL_PORT:587} + username: ${MUH_MAIL_USERNAME:} + password: ${MUH_MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: ${MUH_MAIL_AUTH:false} + starttls: + enable: ${MUH_MAIL_STARTTLS:false} + +muh: + cors: + allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000} + mail: + enabled: ${MUH_MAIL_ENABLED:false} + from: ${MUH_MAIL_FROM:no-reply@muh.local} diff --git a/backend/src/test/java/de/svencarstensen/muh/service/SampleWorkflowRulesTest.java b/backend/src/test/java/de/svencarstensen/muh/service/SampleWorkflowRulesTest.java new file mode 100644 index 0000000..701c17f --- /dev/null +++ b/backend/src/test/java/de/svencarstensen/muh/service/SampleWorkflowRulesTest.java @@ -0,0 +1,34 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.PathogenKind; +import de.svencarstensen.muh.domain.QuarterFinding; +import de.svencarstensen.muh.domain.QuarterKey; +import de.svencarstensen.muh.domain.SampleWorkflowStep; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SampleWorkflowRulesTest { + + @Test + void shouldSkipAntibiogramIfNoBacterialGrowthExists() { + List quarters = List.of( + new QuarterFinding(QuarterKey.SINGLE, false, "ng", "NG", "Kein Wachstum", PathogenKind.NO_GROWTH, null, null) + ); + + assertEquals(SampleWorkflowStep.THERAPY, SampleWorkflowRules.nextStepAfterAnamnesis(quarters)); + } + + @Test + void shouldReturnOnlyOneAntibiogramTargetForSamePathogen() { + List quarters = List.of( + new QuarterFinding(QuarterKey.LEFT_FRONT, false, "sau", "SAU", "Staph. aureus", PathogenKind.BACTERIAL, null, null), + new QuarterFinding(QuarterKey.RIGHT_FRONT, false, "sau", "SAU", "Staph. aureus", PathogenKind.BACTERIAL, null, null), + new QuarterFinding(QuarterKey.LEFT_REAR, false, "eco", "ECO", "E. coli", PathogenKind.BACTERIAL, null, null) + ); + + assertEquals(List.of(QuarterKey.LEFT_FRONT, QuarterKey.LEFT_REAR), SampleWorkflowRules.antibiogramTargets(quarters)); + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ff45b07 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + MUH App + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..dff6c22 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1786 @@ +{ + "name": "muh-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "muh-frontend", + "version": "0.0.1", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", + "integrity": "sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.2.66", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz", + "integrity": "sha512-OYTmMI4UigXeFMF/j4uv0lBBEbongSgptPrHBxqME44h9+yNov+oL6Z3ocJKo0WyXR84sQUNeyIp9MRfckvZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.22", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz", + "integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", + "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.5", + "@babel/plugin-transform-react-jsx-self": "^7.23.3", + "@babel/plugin-transform-react-jsx-source": "^7.23.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.23.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz", + "integrity": "sha512-fzcOaRF69uvqbbM7OhvQyBTFDVrrGlsFdS3AL+1KfIBtGETibHzi3FkoTRyiDJnWNc2VxrfvR+657ROHjaNjqQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.16.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.23.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.1.tgz", + "integrity": "sha512-utP+K+aSTtEdbWpC+4gxhdlPFwuEfDKq8ZrPFU65bbRJY+l706qjR7yaidBpo3MSeA/fzwbXWbKBI6ftOnP3OQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.16.1", + "react-router": "6.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.10.tgz", + "integrity": "sha512-PAzgUZbP7msvQvqdSD+ErD5qGnSFiGOoWmV5yAKUEI0kdhjbH6nMWVyZQC/hSc4aXwc0oJ9aEdIiF9Oje0JFCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2b1410c --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2eab8af --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ; + } + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} + +function ApplicationRouter() { + const { user } = useSession(); + if (!user) { + return ( + + } /> + + ); + } + return ; +} + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts new file mode 100644 index 0000000..e37c80e --- /dev/null +++ b/frontend/src/globals.d.ts @@ -0,0 +1 @@ +interface Worker {} diff --git a/frontend/src/layout/AppShell.tsx b/frontend/src/layout/AppShell.tsx new file mode 100644 index 0000000..7705212 --- /dev/null +++ b/frontend/src/layout/AppShell.tsx @@ -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 = { + "/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 ( +
+ + +
+
+
+

{resolvePageTitle(location.pathname)}

+
+ +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..3fcf67e --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,58 @@ +const API_ROOT = import.meta.env.VITE_API_URL ?? "http://localhost:8090/api"; + +async function handleResponse(response: Response): Promise { + 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(path: string): Promise { + return handleResponse(await fetch(`${API_ROOT}${path}`)); +} + +export async function apiPost(path: string, body: unknown): Promise { + return handleResponse( + await fetch(`${API_ROOT}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +export async function apiPut(path: string, body: unknown): Promise { + return handleResponse( + await fetch(`${API_ROOT}${path}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +export async function apiPatch(path: string, body: unknown): Promise { + return handleResponse( + await fetch(`${API_ROOT}${path}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +export async function apiDelete(path: string): Promise { + await handleResponse( + await fetch(`${API_ROOT}${path}`, { + method: "DELETE", + }), + ); +} + +export function pdfUrl(sampleId: string): string { + return `${API_ROOT}/portal/reports/${sampleId}/pdf`; +} diff --git a/frontend/src/lib/session.tsx b/frontend/src/lib/session.tsx new file mode 100644 index 0000000..0acaab6 --- /dev/null +++ b/frontend/src/lib/session.tsx @@ -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({ + 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(() => 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 {children}; +} + +export function useSession() { + return useContext(SessionContext); +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..5c94f68 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1 @@ +export const USER_STORAGE_KEY = "muh.current-user"; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..b2867d0 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -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[]; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a363633 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + , +); diff --git a/frontend/src/pages/AdministrationPage.tsx b/frontend/src/pages/AdministrationPage.tsx new file mode 100644 index 0000000..e78b13c --- /dev/null +++ b/frontend/src/pages/AdministrationPage.tsx @@ -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; + +const DATASET_LABELS: Record = { + 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(null); + const [selectedDataset, setSelectedDataset] = useState("farmers"); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + async function load() { + try { + const response = await apiGet("/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) { + 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("/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("/admin/medications", rows.map((row) => ({ + id: row.id || null, + name: row.name, + category: row.category, + active: row.active, + }))); + break; + case "pathogens": + response = await apiPost("/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("/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 ( +
+
+
+

Verwaltung

+

Stammdaten direkt pflegen

+

+ Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte + Satz inaktiv sichtbar. +

+
+ + {message ? ( +
+ {message} +
+ ) : null} +
+ +
+
+
+

Datensatz

+

{DATASET_LABELS[selectedDataset]}

+
+ +
+ {(Object.keys(DATASET_LABELS) as DatasetKey[]).map((dataset) => ( + + ))} +
+
+ +
+ + + + + {selectedDataset === "farmers" ? : null} + {selectedDataset === "medications" ? : null} + {selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? : null} + {selectedDataset === "pathogens" ? : null} + + + + + {rows.map((row, index) => ( + + + {selectedDataset === "farmers" ? ( + + ) : null} + {selectedDataset === "medications" ? ( + + ) : null} + {selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? ( + + ) : null} + {selectedDataset === "pathogens" ? ( + + ) : null} + + + ))} + +
NameE-MailKategorieKuerzelTypAktiv
+ updateRow(index, { name: event.target.value })} + /> + + updateRow(index, { email: event.target.value })} + /> + + + + updateRow(index, { code: event.target.value })} + /> + + + + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/AnamnesisPage.tsx b/frontend/src/pages/AnamnesisPage.tsx new file mode 100644 index 0000000..158989c --- /dev/null +++ b/frontend/src/pages/AnamnesisPage.tsx @@ -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>((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(null); + const [sample, setSample] = useState(null); + const [quarterStates, setQuarterStates] = useState>({}); + const [activeQuarter, setActiveQuarter] = useState(null); + const [message, setMessage] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function load() { + if (!sampleId) { + return; + } + try { + const [catalogResponse, sampleResponse] = await Promise.all([ + apiGet("/catalogs/summary"), + apiGet(`/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(() => { + if (!sample) { + return null; + } + return sample.quarters.find((quarter) => quarter.quarterKey === activeQuarter) ?? sample.quarters[0] ?? null; + }, [activeQuarter, sample]); + + function updateQuarter(quarterKey: QuarterKey, patch: Partial) { + setQuarterStates((current) => ({ + ...current, + [quarterKey]: { + ...current[quarterKey], + ...patch, + }, + })); + } + + async function handleSave() { + if (!sampleId || !sample) { + return; + } + setSaving(true); + setMessage(null); + + try { + const response = await apiPut(`/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
Anamnese wird geladen ...
; + } + + const state = quarterStates[visibleQuarter.quarterKey] ?? { + pathogenBusinessKey: "", + customPathogenName: "", + cellCount: "", + }; + + return ( +
+
+
+

Anamnese

+

Probe {sample.sampleNumber}

+

+ Erreger koennen ueber Schnellwahl oder Freitext erfasst werden. Bei 4/4-Proben wird + jedes relevante Viertel separat dokumentiert. +

+
+ + {sample.anamnesisEditable ? null : ( +
+ Die Anamnese ist in diesem Bearbeitungsstand nur noch lesbar. +
+ )} + + {message ?
{message}
: null} +
+ + {sample.quarters.length > 1 ? ( +
+
+ {sample.quarters.map((quarter) => ( + + ))} +
+
+ ) : null} + +
+
+

Entnahmestelle

+

{visibleQuarter.label}

+ {visibleQuarter.flagged ? ( +
Auffaelliges Viertel markiert
+ ) : null} + +
+ {catalogs.pathogens.map((pathogen) => ( + + ))} +
+ + +
+ +
+

Begleitdaten

+ + +
+ Hinweis +

+ Kein Wachstum oder verunreinigte Proben werden spaeter automatisch vom + Antibiogramm ausgeschlossen. +

+
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/pages/AntibiogramPage.tsx b/frontend/src/pages/AntibiogramPage.tsx new file mode 100644 index 0000000..13dbd43 --- /dev/null +++ b/frontend/src/pages/AntibiogramPage.tsx @@ -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; + +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(null); + const [sample, setSample] = useState(null); + const [groupState, setGroupState] = useState>({}); + const [message, setMessage] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function load() { + if (!sampleId) { + return; + } + try { + const [catalogResponse, sampleResponse] = await Promise.all([ + apiGet("/catalogs/summary"), + apiGet(`/samples/${sampleId}`), + ]); + setCatalogs(catalogResponse); + setSample(sampleResponse); + + const nextState: Record = {}; + 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(`/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
Antibiogramm wird geladen ...
; + } + + return ( +
+
+
+

Antibiogramm

+

Probe {sample.sampleNumber}

+

+ Nur Viertel mit bakteriellem Wachstum werden angezeigt. Identische Erreger werden + automatisch zusammengefasst. +

+
+ + {sample.antibiogramEditable ? null : ( +
+ Das Antibiogramm ist in diesem Bearbeitungsstand nur noch lesbar. +
+ )} + + {message ?
{message}
: null} +
+ + {!groups.length ? ( +
Fuer diese Probe ist kein Antibiogramm erforderlich.
+ ) : ( + groups.map((group) => ( +
+
+
+

{group.reference?.label}

+

{group.reference?.customPathogenName || group.reference?.pathogenName || "Erreger"}

+
+ {group.inherited.length ? ( +
+ Gilt ebenfalls fuer {group.inherited.map((entry) => entry.label).join(", ")} +
+ ) : null} +
+ +
+ + + + + + + + + + + {catalogs.antibiotics.map((antibiotic) => ( + + + {(["SENSITIVE", "INTERMEDIATE", "RESISTANT"] as SensitivityResult[]).map((result) => ( + + ))} + + ))} + +
AntibiotikumSIR
+ {antibiotic.name} + {antibiotic.code ?? "ANT"} + + +
+
+
+ )) + )} + +
+ +
+
+ ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..1656af8 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -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 = { + ANAMNESIS: "Anamnese", + ANTIBIOGRAM: "Antibiogramm", + THERAPY: "Therapie", + COMPLETED: "Abgeschlossen", +}; + +export default function HomePage() { + const navigate = useNavigate(); + const [dashboard, setDashboard] = useState(null); + const [sampleNumber, setSampleNumber] = useState(""); + const [message, setMessage] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadDashboard() { + try { + const response = await apiGet("/dashboard"); + setDashboard(response); + } finally { + setLoading(false); + } + } + + void loadDashboard(); + }, []); + + async function handleLookup(event: FormEvent) { + event.preventDefault(); + if (!sampleNumber.trim()) { + setMessage("Bitte eine Probennummer eingeben."); + return; + } + + try { + const response = await apiGet(`/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 ( +
+
+
+

Startseite

+

Bearbeitungsstand sofort finden

+

+ Eine bekannte Probennummer oeffnet direkt den passenden Arbeitsschritt. +

+
+ +
+ + + +
+ + {message ?
{message}
: null} +
+ +
+
+ Naechste Nummer + {dashboard?.nextSampleNumber ?? "..."} +
+
+ Offene Proben + {dashboard?.openSamples ?? "..."} +
+
+ Heute abgeschlossen + {dashboard?.completedToday ?? "..."} +
+
+ +
+
+
+

Arbeitsvorrat

+

Zuletzt bearbeitete Proben

+
+
+ + {loading ? ( +
Dashboard wird geladen ...
+ ) : dashboard?.recentSamples.length ? ( +
+ + + + + + + + + + + + + {dashboard.recentSamples.map((sample) => ( + + + + + + + + + + ))} + +
ProbeLandwirtKuhTypStatusAktualisiert +
{sample.sampleNumber}{sample.farmerName}{sample.cowLabel}{sample.sampleKind === "DRY_OFF" ? "Trockensteller" : "Laktation"} + + {STEP_LABELS[sample.currentStep]} + + {formatDate(sample.updatedAt)} + +
+
+ ) : ( +
Noch keine Proben vorhanden.
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2864f64 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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([]); + 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(null); + const { setUser } = useSession(); + + async function loadUsers() { + setLoading(true); + setFeedback(null); + try { + const response = await apiGet("/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("/session/login", { code }); + setUser(response); + } catch (loginError) { + setFeedback({ type: "error", text: (loginError as Error).message }); + } + } + + async function handlePasswordLogin(event: FormEvent) { + event.preventDefault(); + try { + const response = await apiPost("/session/password-login", { + identifier, + password, + }); + setUser(response); + } catch (loginError) { + setFeedback({ type: "error", text: (loginError as Error).message }); + } + } + + async function handleRegister(event: FormEvent) { + event.preventDefault(); + try { + const response = await apiPost("/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 ( +
+
+
+

MUH-App

+

Moderne Steuerung fuer Milchproben und Therapien.

+

+ Fokus auf klare Arbeitsablaeufe, schnelle Probenbearbeitung und ein Portal + fuer Verwaltung, Berichtsdruck und Versandstatus. +

+
+ +
+
+

Zugang

+

Anmelden oder registrieren

+

+ Weiterhin moeglich: Direktanmeldung per Benutzerkuerzel. Neu: Login mit + E-Mail/Benutzername und Passwort. +

+ + {feedback ? ( +
+ {feedback.text} +
+ ) : null} + +
+
+
+

Schnelllogin

+

Benutzerkuerzel

+
+ +
+ + {loading ? ( +
Benutzer werden geladen ...
+ ) : users.length ? ( +
+ {users.map((user) => ( + + ))} +
+ ) : ( +
+
+ Es wurden keine aktiven Benutzer geladen. Das Kuersel kann trotzdem direkt + eingegeben werden. +
+ +
+ +
+
+ )} +
+ +
oder mit Passwort
+ +
+
+

Login

+

E-Mail oder Benutzername

+ + +
+ +
+
+ +
+

Kundenregistrierung

+

Neues Kundenkonto anlegen

+ +