Initial MUH app implementation
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
backend/target/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
.DS_Store
|
||||||
47
.vscode/launch.json
vendored
Normal file
47
.vscode/launch.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
78
README.md
Normal file
78
README.md
Normal file
@@ -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`
|
||||||
59
backend/pom.xml
Normal file
59
backend/pom.xml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>de.svencarstensen</groupId>
|
||||||
|
<artifactId>muh-backend</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>muh-backend</name>
|
||||||
|
<description>MUH application backend</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public record AntibiogramEntry(
|
||||||
|
String antibioticBusinessKey,
|
||||||
|
String antibioticCode,
|
||||||
|
String antibioticName,
|
||||||
|
SensitivityResult result
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public enum MedicationCategory {
|
||||||
|
IN_UDDER,
|
||||||
|
SYSTEMIC_ANTIBIOTIC,
|
||||||
|
SYSTEMIC_PAIN,
|
||||||
|
DRY_SEALER,
|
||||||
|
DRY_ANTIBIOTIC
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public enum PathogenKind {
|
||||||
|
BACTERIAL,
|
||||||
|
NO_GROWTH,
|
||||||
|
CONTAMINATED,
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
@@ -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<AntibiogramEntry> entries
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QuarterFinding> quarters,
|
||||||
|
List<QuarterAntibiogram> antibiograms,
|
||||||
|
TherapyRecommendation therapyRecommendation,
|
||||||
|
boolean reportSent,
|
||||||
|
boolean reportBlocked,
|
||||||
|
LocalDateTime reportSentAt,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt,
|
||||||
|
LocalDateTime completedAt,
|
||||||
|
String createdByUserCode,
|
||||||
|
String createdByDisplayName
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public enum SampleKind {
|
||||||
|
LACTATION,
|
||||||
|
DRY_OFF
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public enum SampleWorkflowStep {
|
||||||
|
ANAMNESIS,
|
||||||
|
ANTIBIOGRAM,
|
||||||
|
THERAPY,
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public enum SamplingMode {
|
||||||
|
SINGLE_SITE,
|
||||||
|
FOUR_QUARTER,
|
||||||
|
UNKNOWN_SITE
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public enum SensitivityResult {
|
||||||
|
SENSITIVE,
|
||||||
|
INTERMEDIATE,
|
||||||
|
RESISTANT
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record TherapyRecommendation(
|
||||||
|
boolean continueStarted,
|
||||||
|
boolean switchTherapy,
|
||||||
|
List<String> inUdderMedicationKeys,
|
||||||
|
List<String> inUdderMedicationNames,
|
||||||
|
String inUdderOther,
|
||||||
|
List<String> systemicMedicationKeys,
|
||||||
|
List<String> systemicMedicationNames,
|
||||||
|
String systemicOther,
|
||||||
|
List<String> drySealerKeys,
|
||||||
|
List<String> drySealerNames,
|
||||||
|
List<String> dryAntibioticKeys,
|
||||||
|
List<String> dryAntibioticNames,
|
||||||
|
String farmerNote,
|
||||||
|
String internalNote
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.svencarstensen.muh.domain;
|
||||||
|
|
||||||
|
public enum UserRole {
|
||||||
|
APP,
|
||||||
|
ADMIN,
|
||||||
|
CUSTOMER
|
||||||
|
}
|
||||||
@@ -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<AntibioticCatalogItem, String> {
|
||||||
|
List<AntibioticCatalogItem> findByActiveTrueOrderByNameAsc();
|
||||||
|
}
|
||||||
@@ -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<AppUser, String> {
|
||||||
|
List<AppUser> findByActiveTrueOrderByDisplayNameAsc();
|
||||||
|
|
||||||
|
Optional<AppUser> findByCodeIgnoreCase(String code);
|
||||||
|
|
||||||
|
Optional<AppUser> findByEmailIgnoreCase(String email);
|
||||||
|
|
||||||
|
Optional<AppUser> findByPortalLoginIgnoreCase(String portalLogin);
|
||||||
|
}
|
||||||
@@ -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<Farmer, String> {
|
||||||
|
List<Farmer> findByActiveTrueOrderByNameAsc();
|
||||||
|
|
||||||
|
List<Farmer> findByNameContainingIgnoreCaseOrderByNameAsc(String name);
|
||||||
|
}
|
||||||
@@ -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<MedicationCatalogItem, String> {
|
||||||
|
List<MedicationCatalogItem> findByActiveTrueOrderByNameAsc();
|
||||||
|
}
|
||||||
@@ -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<PathogenCatalogItem, String> {
|
||||||
|
List<PathogenCatalogItem> findByActiveTrueOrderByNameAsc();
|
||||||
|
}
|
||||||
@@ -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<Sample, String> {
|
||||||
|
Optional<Sample> findBySampleNumber(long sampleNumber);
|
||||||
|
|
||||||
|
Optional<Sample> findTopByOrderBySampleNumberDesc();
|
||||||
|
|
||||||
|
List<Sample> findTop12ByOrderByUpdatedAtDesc();
|
||||||
|
|
||||||
|
List<Sample> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey);
|
||||||
|
|
||||||
|
List<Sample> findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end);
|
||||||
|
|
||||||
|
List<Sample> findByCompletedAtNotNullOrderByCompletedAtDesc();
|
||||||
|
}
|
||||||
@@ -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<FarmerRow> 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<MedicationRow> MEDICATION_ROW_COMPARATOR = Comparator
|
||||||
|
.comparing(MedicationRow::active).reversed()
|
||||||
|
.thenComparing(MedicationRow::category)
|
||||||
|
.thenComparing(MedicationRow::name, String.CASE_INSENSITIVE_ORDER);
|
||||||
|
|
||||||
|
private static final Comparator<PathogenRow> 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<AntibioticRow> 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<FarmerRow> listFarmerRows() {
|
||||||
|
return farmerRepository.findAll().stream()
|
||||||
|
.map(this::toFarmerRow)
|
||||||
|
.sorted(FARMER_ROW_COMPARATOR)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MedicationRow> listMedicationRows() {
|
||||||
|
return medicationRepository.findAll().stream()
|
||||||
|
.map(this::toMedicationRow)
|
||||||
|
.sorted(MEDICATION_ROW_COMPARATOR)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PathogenRow> listPathogenRows() {
|
||||||
|
return pathogenRepository.findAll().stream()
|
||||||
|
.map(this::toPathogenRow)
|
||||||
|
.sorted(PATHOGEN_ROW_COMPARATOR)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AntibioticRow> listAntibioticRows() {
|
||||||
|
return antibioticRepository.findAll().stream()
|
||||||
|
.map(this::toAntibioticRow)
|
||||||
|
.sorted(ANTIBIOTIC_ROW_COMPARATOR)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FarmerRow> saveFarmers(List<FarmerMutation> 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<MedicationRow> saveMedications(List<MedicationMutation> 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<PathogenRow> savePathogens(List<PathogenMutation> 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<AntibioticRow> saveAntibiotics(List<AntibioticMutation> 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<UserRow> 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<AppUser> activeUsers() {
|
||||||
|
ensureDefaultUsers();
|
||||||
|
return appUserRepository.findByActiveTrueOrderByDisplayNameAsc();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<AppUser> 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<String, PathogenCatalogItem> activePathogensByBusinessKey() {
|
||||||
|
return pathogenRepository.findByActiveTrueOrderByNameAsc().stream()
|
||||||
|
.collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey() {
|
||||||
|
return antibioticRepository.findByActiveTrueOrderByNameAsc().stream()
|
||||||
|
.collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, MedicationCatalogItem> 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<AppUser> 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<String> 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<FarmerOption> farmers,
|
||||||
|
List<MedicationOption> medications,
|
||||||
|
List<PathogenOption> pathogens,
|
||||||
|
List<AntibioticOption> antibiotics,
|
||||||
|
List<UserOption> users
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AdministrationOverview(
|
||||||
|
List<FarmerRow> farmers,
|
||||||
|
List<MedicationRow> medications,
|
||||||
|
List<PathogenRow> pathogens,
|
||||||
|
List<AntibioticRow> 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary().farmers().stream()
|
||||||
|
.filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<PortalSampleRow> 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<CatalogService.FarmerOption> farmers,
|
||||||
|
List<PortalSampleRow> samples,
|
||||||
|
List<ReportService.ReportCandidate> reportCandidates,
|
||||||
|
List<CatalogService.UserRow> 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JavaMailSender> mailSenderProvider;
|
||||||
|
private final boolean mailEnabled;
|
||||||
|
private final String mailFrom;
|
||||||
|
|
||||||
|
public ReportService(
|
||||||
|
SampleService sampleService,
|
||||||
|
ObjectProvider<JavaMailSender> 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<ReportCandidate> reportCandidates() {
|
||||||
|
return sampleService.completedSamples().stream()
|
||||||
|
.filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank())
|
||||||
|
.map(this::toCandidate)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DispatchResult sendReports(List<String> sampleIds) {
|
||||||
|
List<ReportCandidate> sent = new ArrayList<>();
|
||||||
|
List<ReportCandidate> 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<String> 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<String> 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<String> 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<Integer> 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<ReportCandidate> sent,
|
||||||
|
List<ReportCandidate> skipped,
|
||||||
|
boolean mailDeliveryActive
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SampleSummary> 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<QuarterKey, QuarterFinding> current = new HashMap<>();
|
||||||
|
for (QuarterFinding quarter : existing.quarters()) {
|
||||||
|
current.put(quarter.quarterKey(), quarter);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey();
|
||||||
|
List<QuarterFinding> 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<String, de.svencarstensen.muh.domain.AntibioticCatalogItem> antibiotics = catalogService.activeAntibioticsByBusinessKey();
|
||||||
|
Map<QuarterKey, QuarterAntibiogram> groups = new HashMap<>();
|
||||||
|
Map<QuarterKey, QuarterFinding> 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<AntibiogramEntry> 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<QuarterAntibiogram> 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<String, de.svencarstensen.muh.domain.MedicationCatalogItem> 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<Sample> completedSamples() {
|
||||||
|
return sampleRepository.findByCompletedAtNotNullOrderByCompletedAtDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Sample> samplesByFarmerBusinessKey(String businessKey) {
|
||||||
|
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Sample> 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<QuarterView> 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<AntibiogramView> 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<String> resolveMedicationNames(List<String> keys, Map<String, de.svencarstensen.muh.domain.MedicationCatalogItem> medications) {
|
||||||
|
return keys == null ? List.of() : keys.stream()
|
||||||
|
.map(medications::get)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(de.svencarstensen.muh.domain.MedicationCatalogItem::name)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<QuarterFinding> 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<SampleSummary> 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<AntibiogramEntryView> entries
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TherapyView(
|
||||||
|
boolean continueStarted,
|
||||||
|
boolean switchTherapy,
|
||||||
|
List<String> inUdderMedicationKeys,
|
||||||
|
List<String> inUdderMedicationNames,
|
||||||
|
String inUdderOther,
|
||||||
|
List<String> systemicMedicationKeys,
|
||||||
|
List<String> systemicMedicationNames,
|
||||||
|
String systemicOther,
|
||||||
|
List<String> drySealerKeys,
|
||||||
|
List<String> drySealerNames,
|
||||||
|
List<String> dryAntibioticKeys,
|
||||||
|
List<String> 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<QuarterView> quarters,
|
||||||
|
List<AntibiogramView> antibiograms,
|
||||||
|
TherapyView therapy,
|
||||||
|
List<QuarterKey> 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<QuarterKey> flaggedQuarters,
|
||||||
|
String userCode,
|
||||||
|
String userDisplayName
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AnamnesisQuarterRequest(
|
||||||
|
QuarterKey quarterKey,
|
||||||
|
String pathogenBusinessKey,
|
||||||
|
String customPathogenName,
|
||||||
|
Integer cellCount
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AnamnesisRequest(List<AnamnesisQuarterRequest> quarters) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AntibiogramLineRequest(String antibioticBusinessKey, SensitivityResult result) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AntibiogramGroupRequest(QuarterKey referenceQuarter, List<AntibiogramLineRequest> entries) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AntibiogramRequest(List<AntibiogramGroupRequest> groups) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TherapyRequest(
|
||||||
|
boolean continueStarted,
|
||||||
|
boolean switchTherapy,
|
||||||
|
List<String> inUdderMedicationKeys,
|
||||||
|
String inUdderOther,
|
||||||
|
List<String> systemicMedicationKeys,
|
||||||
|
String systemicOther,
|
||||||
|
List<String> drySealerKeys,
|
||||||
|
List<String> dryAntibioticKeys,
|
||||||
|
String farmerNote,
|
||||||
|
String internalNote
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QuarterFinding> 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<QuarterKey> antibiogramTargets(List<QuarterFinding> quarters) {
|
||||||
|
Map<String, QuarterKey> firstByPathogen = new HashMap<>();
|
||||||
|
List<QuarterKey> 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<QuarterFinding> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) {
|
||||||
|
return catalogService.saveFarmers(mutations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/medications")
|
||||||
|
public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) {
|
||||||
|
return catalogService.saveMedications(mutations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/pathogens")
|
||||||
|
public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) {
|
||||||
|
return catalogService.savePathogens(mutations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/antibiotics")
|
||||||
|
public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
|
||||||
|
return catalogService.saveAntibiotics(mutations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/portal/users")
|
||||||
|
public List<CatalogService.UserRow> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReportService.ReportCandidate> 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<byte[]> 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<String> sampleIds) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BlockRequest(boolean blocked) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CatalogService.UserOption> 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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/main/resources/application.yml
Normal file
29
backend/src/main/resources/application.yml
Normal file
@@ -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}
|
||||||
@@ -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<QuarterFinding> 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<QuarterFinding> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MUH App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1786
frontend/package-lock.json
generated
Normal file
1786
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "muh-frontend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-router-dom": "6.23.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.4.0",
|
||||||
|
"@types/react": "18.2.66",
|
||||||
|
"@types/react-dom": "18.2.22",
|
||||||
|
"@types/scheduler": "^0.26.0",
|
||||||
|
"@vitejs/plugin-react": "4.2.1",
|
||||||
|
"typescript": "5.4.5",
|
||||||
|
"vite": "5.2.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
frontend/src/App.tsx
Normal file
55
frontend/src/App.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { SessionProvider, useSession } from "./lib/session";
|
||||||
|
import AppShell from "./layout/AppShell";
|
||||||
|
import HomePage from "./pages/HomePage";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import SampleRegistrationPage from "./pages/SampleRegistrationPage";
|
||||||
|
import AnamnesisPage from "./pages/AnamnesisPage";
|
||||||
|
import AntibiogramPage from "./pages/AntibiogramPage";
|
||||||
|
import TherapyPage from "./pages/TherapyPage";
|
||||||
|
import AdministrationPage from "./pages/AdministrationPage";
|
||||||
|
import PortalPage from "./pages/PortalPage";
|
||||||
|
|
||||||
|
function ProtectedRoutes() {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route path="/home" element={<HomePage />} />
|
||||||
|
<Route path="/samples/new" element={<SampleRegistrationPage />} />
|
||||||
|
<Route path="/samples/:sampleId/registration" element={<SampleRegistrationPage />} />
|
||||||
|
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
|
||||||
|
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
|
||||||
|
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
|
||||||
|
<Route path="/admin" element={<AdministrationPage />} />
|
||||||
|
<Route path="/portal" element={<PortalPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicationRouter() {
|
||||||
|
const { user } = useSession();
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="*" element={<LoginPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ProtectedRoutes />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<ApplicationRouter />
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/globals.d.ts
vendored
Normal file
1
frontend/src/globals.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
interface Worker {}
|
||||||
95
frontend/src/layout/AppShell.tsx
Normal file
95
frontend/src/layout/AppShell.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useSession } from "../lib/session";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: "/home", label: "Start" },
|
||||||
|
{ to: "/samples/new", label: "Neue Probe" },
|
||||||
|
{ to: "/admin", label: "Verwaltung" },
|
||||||
|
{ to: "/portal", label: "Portal" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
|
"/home": "Startseite",
|
||||||
|
"/samples/new": "Neuanlage einer Probe",
|
||||||
|
"/admin": "Verwaltung",
|
||||||
|
"/portal": "MUH-Portal",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvePageTitle(pathname: string) {
|
||||||
|
if (pathname.includes("/anamnesis")) {
|
||||||
|
return "Anamnese";
|
||||||
|
}
|
||||||
|
if (pathname.includes("/antibiogram")) {
|
||||||
|
return "Antibiogramm";
|
||||||
|
}
|
||||||
|
if (pathname.includes("/therapy")) {
|
||||||
|
return "Therapieempfehlung";
|
||||||
|
}
|
||||||
|
if (pathname.includes("/registration")) {
|
||||||
|
return "Probe bearbeiten";
|
||||||
|
}
|
||||||
|
return PAGE_TITLES[pathname] ?? "MUH App";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppShell() {
|
||||||
|
const { user, setUser } = useSession();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar__brand">
|
||||||
|
<div className="sidebar__logo">MUH</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar__nav">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar__footer">
|
||||||
|
<div className="user-chip user-chip--stacked">
|
||||||
|
<span>{user?.displayName}</span>
|
||||||
|
<small>{user?.code}</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
setUser(null);
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="shell-main">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar__headline">
|
||||||
|
<h2>{resolvePageTitle(location.pathname)}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topbar__actions">
|
||||||
|
<button type="button" className="accent-button" onClick={() => navigate("/samples/new")}>
|
||||||
|
Neuanlage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="content-area">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/lib/api.ts
Normal file
58
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const API_ROOT = import.meta.env.VITE_API_URL ?? "http://localhost:8090/api";
|
||||||
|
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(text || "Unbekannter API-Fehler");
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
|
return handleResponse<T>(await fetch(`${API_ROOT}${path}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return handleResponse<T>(
|
||||||
|
await fetch(`${API_ROOT}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return handleResponse<T>(
|
||||||
|
await fetch(`${API_ROOT}${path}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return handleResponse<T>(
|
||||||
|
await fetch(`${API_ROOT}${path}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete(path: string): Promise<void> {
|
||||||
|
await handleResponse<void>(
|
||||||
|
await fetch(`${API_ROOT}${path}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfUrl(sampleId: string): string {
|
||||||
|
return `${API_ROOT}/portal/reports/${sampleId}/pdf`;
|
||||||
|
}
|
||||||
58
frontend/src/lib/session.tsx
Normal file
58
frontend/src/lib/session.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type PropsWithChildren,
|
||||||
|
} from "react";
|
||||||
|
import { USER_STORAGE_KEY } from "./storage";
|
||||||
|
import type { UserOption } from "./types";
|
||||||
|
|
||||||
|
interface SessionContextValue {
|
||||||
|
user: UserOption | null;
|
||||||
|
setUser: (user: UserOption | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionContextValue>({
|
||||||
|
user: null,
|
||||||
|
setUser: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadStoredUser(): UserOption | null {
|
||||||
|
const raw = window.localStorage.getItem(USER_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as UserOption;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionProvider({ children }: PropsWithChildren) {
|
||||||
|
const [user, setUserState] = useState<UserOption | null>(() => loadStoredUser());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
window.localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.removeItem(USER_STORAGE_KEY);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
setUser: setUserState,
|
||||||
|
}),
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return useContext(SessionContext);
|
||||||
|
}
|
||||||
1
frontend/src/lib/storage.ts
Normal file
1
frontend/src/lib/storage.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const USER_STORAGE_KEY = "muh.current-user";
|
||||||
249
frontend/src/lib/types.ts
Normal file
249
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
export type SampleKind = "LACTATION" | "DRY_OFF";
|
||||||
|
export type SamplingMode = "SINGLE_SITE" | "FOUR_QUARTER" | "UNKNOWN_SITE";
|
||||||
|
export type SampleWorkflowStep = "ANAMNESIS" | "ANTIBIOGRAM" | "THERAPY" | "COMPLETED";
|
||||||
|
export type QuarterKey =
|
||||||
|
| "SINGLE"
|
||||||
|
| "UNKNOWN"
|
||||||
|
| "LEFT_FRONT"
|
||||||
|
| "RIGHT_FRONT"
|
||||||
|
| "LEFT_REAR"
|
||||||
|
| "RIGHT_REAR";
|
||||||
|
export type PathogenKind = "BACTERIAL" | "NO_GROWTH" | "CONTAMINATED" | "OTHER";
|
||||||
|
export type SensitivityResult = "SENSITIVE" | "INTERMEDIATE" | "RESISTANT";
|
||||||
|
export type MedicationCategory =
|
||||||
|
| "IN_UDDER"
|
||||||
|
| "SYSTEMIC_ANTIBIOTIC"
|
||||||
|
| "SYSTEMIC_PAIN"
|
||||||
|
| "DRY_SEALER"
|
||||||
|
| "DRY_ANTIBIOTIC";
|
||||||
|
export type UserRole = "APP" | "ADMIN" | "CUSTOMER";
|
||||||
|
|
||||||
|
export interface FarmerOption {
|
||||||
|
businessKey: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedicationOption {
|
||||||
|
businessKey: string;
|
||||||
|
name: string;
|
||||||
|
category: MedicationCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathogenOption {
|
||||||
|
businessKey: string;
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
kind: PathogenKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AntibioticOption {
|
||||||
|
businessKey: string;
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserOption {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
displayName: string;
|
||||||
|
companyName: string | null;
|
||||||
|
address: string | null;
|
||||||
|
email: string | null;
|
||||||
|
portalLogin: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRow extends UserOption {
|
||||||
|
active: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveCatalogSummary {
|
||||||
|
farmers: FarmerOption[];
|
||||||
|
medications: MedicationOption[];
|
||||||
|
pathogens: PathogenOption[];
|
||||||
|
antibiotics: AntibioticOption[];
|
||||||
|
users: UserOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSampleSummary {
|
||||||
|
id: string;
|
||||||
|
sampleNumber: number;
|
||||||
|
farmerName: string;
|
||||||
|
cowLabel: string;
|
||||||
|
sampleKind: SampleKind;
|
||||||
|
currentStep: SampleWorkflowStep;
|
||||||
|
updatedAt: string;
|
||||||
|
reportSent: boolean;
|
||||||
|
reportBlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOverview {
|
||||||
|
nextSampleNumber: number;
|
||||||
|
openSamples: number;
|
||||||
|
completedToday: number;
|
||||||
|
recentSamples: DashboardSampleSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LookupResult {
|
||||||
|
found: boolean;
|
||||||
|
message: string;
|
||||||
|
sampleId: string | null;
|
||||||
|
step: SampleWorkflowStep | null;
|
||||||
|
routeSegment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuarterView {
|
||||||
|
quarterKey: QuarterKey;
|
||||||
|
label: string;
|
||||||
|
flagged: boolean;
|
||||||
|
pathogenBusinessKey: string | null;
|
||||||
|
pathogenCode: string | null;
|
||||||
|
pathogenName: string | null;
|
||||||
|
pathogenKind: PathogenKind | null;
|
||||||
|
customPathogenName: string | null;
|
||||||
|
cellCount: number | null;
|
||||||
|
requiresAntibiogram: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AntibiogramEntryView {
|
||||||
|
antibioticBusinessKey: string;
|
||||||
|
antibioticCode: string | null;
|
||||||
|
antibioticName: string;
|
||||||
|
result: SensitivityResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AntibiogramView {
|
||||||
|
quarterKey: QuarterKey;
|
||||||
|
pathogenName: string;
|
||||||
|
inheritedFromQuarter: QuarterKey | null;
|
||||||
|
entries: AntibiogramEntryView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TherapyView {
|
||||||
|
continueStarted: boolean;
|
||||||
|
switchTherapy: boolean;
|
||||||
|
inUdderMedicationKeys: string[];
|
||||||
|
inUdderMedicationNames: string[];
|
||||||
|
inUdderOther: string | null;
|
||||||
|
systemicMedicationKeys: string[];
|
||||||
|
systemicMedicationNames: string[];
|
||||||
|
systemicOther: string | null;
|
||||||
|
drySealerKeys: string[];
|
||||||
|
drySealerNames: string[];
|
||||||
|
dryAntibioticKeys: string[];
|
||||||
|
dryAntibioticNames: string[];
|
||||||
|
farmerNote: string | null;
|
||||||
|
internalNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SampleDetail {
|
||||||
|
id: string;
|
||||||
|
sampleNumber: number;
|
||||||
|
farmerBusinessKey: string;
|
||||||
|
farmerName: string;
|
||||||
|
farmerEmail: string | null;
|
||||||
|
cowNumber: string;
|
||||||
|
cowName: string | null;
|
||||||
|
sampleKind: SampleKind;
|
||||||
|
samplingMode: SamplingMode;
|
||||||
|
currentStep: SampleWorkflowStep;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
createdByUserCode: string;
|
||||||
|
createdByDisplayName: string;
|
||||||
|
reportSent: boolean;
|
||||||
|
reportBlocked: boolean;
|
||||||
|
reportSentAt: string | null;
|
||||||
|
routeSegment: string;
|
||||||
|
quarters: QuarterView[];
|
||||||
|
antibiograms: AntibiogramView[];
|
||||||
|
therapy: TherapyView | null;
|
||||||
|
antibiogramTargets: QuarterKey[];
|
||||||
|
registrationEditable: boolean;
|
||||||
|
anamnesisEditable: boolean;
|
||||||
|
antibiogramEditable: boolean;
|
||||||
|
therapyEditable: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FarmerRow {
|
||||||
|
id: string;
|
||||||
|
businessKey: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
active: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedicationRow {
|
||||||
|
id: string;
|
||||||
|
businessKey: string;
|
||||||
|
name: string;
|
||||||
|
category: MedicationCategory;
|
||||||
|
active: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathogenRow {
|
||||||
|
id: string;
|
||||||
|
businessKey: string;
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
kind: PathogenKind;
|
||||||
|
active: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AntibioticRow {
|
||||||
|
id: string;
|
||||||
|
businessKey: string;
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdministrationOverview {
|
||||||
|
farmers: FarmerRow[];
|
||||||
|
medications: MedicationRow[];
|
||||||
|
pathogens: PathogenRow[];
|
||||||
|
antibiotics: AntibioticRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportCandidate {
|
||||||
|
sampleId: string;
|
||||||
|
sampleNumber: number;
|
||||||
|
farmerName: string;
|
||||||
|
farmerEmail: string;
|
||||||
|
cowLabel: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
reportSent: boolean;
|
||||||
|
reportBlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortalSampleRow {
|
||||||
|
sampleId: string;
|
||||||
|
sampleNumber: number;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
farmerBusinessKey: string;
|
||||||
|
farmerName: string;
|
||||||
|
farmerEmail: string | null;
|
||||||
|
cowNumber: string;
|
||||||
|
cowName: string | null;
|
||||||
|
sampleKindLabel: string;
|
||||||
|
internalNote: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
reportSent: boolean;
|
||||||
|
reportBlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortalSnapshot {
|
||||||
|
farmers: FarmerOption[];
|
||||||
|
samples: PortalSampleRow[];
|
||||||
|
reportCandidates: ReportCandidate[];
|
||||||
|
users: UserRow[];
|
||||||
|
}
|
||||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
326
frontend/src/pages/AdministrationPage.tsx
Normal file
326
frontend/src/pages/AdministrationPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { apiGet, apiPost } from "../lib/api";
|
||||||
|
import type { AdministrationOverview, MedicationCategory, PathogenKind } from "../lib/types";
|
||||||
|
|
||||||
|
type DatasetKey = "farmers" | "medications" | "pathogens" | "antibiotics";
|
||||||
|
|
||||||
|
type EditableRow = {
|
||||||
|
id: string;
|
||||||
|
businessKey: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
email?: string;
|
||||||
|
category?: MedicationCategory;
|
||||||
|
code?: string;
|
||||||
|
kind?: PathogenKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DatasetsState = Record<DatasetKey, EditableRow[]>;
|
||||||
|
|
||||||
|
const DATASET_LABELS: Record<DatasetKey, string> = {
|
||||||
|
farmers: "Landwirte",
|
||||||
|
medications: "Medikamente",
|
||||||
|
pathogens: "Erreger",
|
||||||
|
antibiotics: "Antibiogramm",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeOverview(overview: AdministrationOverview): DatasetsState {
|
||||||
|
return {
|
||||||
|
farmers: overview.farmers.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
businessKey: entry.businessKey,
|
||||||
|
name: entry.name,
|
||||||
|
email: entry.email ?? "",
|
||||||
|
active: entry.active,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
})),
|
||||||
|
medications: overview.medications.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
businessKey: entry.businessKey,
|
||||||
|
name: entry.name,
|
||||||
|
category: entry.category,
|
||||||
|
active: entry.active,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
})),
|
||||||
|
pathogens: overview.pathogens.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
businessKey: entry.businessKey,
|
||||||
|
code: entry.code ?? "",
|
||||||
|
name: entry.name,
|
||||||
|
kind: entry.kind,
|
||||||
|
active: entry.active,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
})),
|
||||||
|
antibiotics: overview.antibiotics.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
businessKey: entry.businessKey,
|
||||||
|
code: entry.code ?? "",
|
||||||
|
name: entry.name,
|
||||||
|
active: entry.active,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyRow(dataset: DatasetKey): EditableRow {
|
||||||
|
switch (dataset) {
|
||||||
|
case "farmers":
|
||||||
|
return { id: "", businessKey: "", name: "", email: "", active: true, updatedAt: new Date().toISOString() };
|
||||||
|
case "medications":
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
businessKey: "",
|
||||||
|
name: "",
|
||||||
|
category: "IN_UDDER",
|
||||||
|
active: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
case "pathogens":
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
businessKey: "",
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
kind: "BACTERIAL",
|
||||||
|
active: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
case "antibiotics":
|
||||||
|
return { id: "", businessKey: "", code: "", name: "", active: true, updatedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdministrationPage() {
|
||||||
|
const [datasets, setDatasets] = useState<DatasetsState | null>(null);
|
||||||
|
const [selectedDataset, setSelectedDataset] = useState<DatasetKey>("farmers");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const response = await apiGet<AdministrationOverview>("/admin");
|
||||||
|
setDatasets(normalizeOverview(response));
|
||||||
|
} catch (loadError) {
|
||||||
|
setMessage((loadError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rows = useMemo(() => datasets?.[selectedDataset] ?? [], [datasets, selectedDataset]);
|
||||||
|
|
||||||
|
function updateRow(index: number, patch: Partial<EditableRow>) {
|
||||||
|
setDatasets((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const nextRows = current[selectedDataset].map((row, rowIndex) =>
|
||||||
|
rowIndex === index ? { ...row, ...patch } : row,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[selectedDataset]: nextRows,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
setDatasets((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[selectedDataset]: [...current[selectedDataset], emptyRow(selectedDataset)],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!datasets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
let response: EditableRow[];
|
||||||
|
switch (selectedDataset) {
|
||||||
|
case "farmers":
|
||||||
|
response = await apiPost<EditableRow[]>("/admin/farmers", rows.map((row) => ({
|
||||||
|
id: row.id || null,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email || null,
|
||||||
|
active: row.active,
|
||||||
|
})));
|
||||||
|
break;
|
||||||
|
case "medications":
|
||||||
|
response = await apiPost<EditableRow[]>("/admin/medications", rows.map((row) => ({
|
||||||
|
id: row.id || null,
|
||||||
|
name: row.name,
|
||||||
|
category: row.category,
|
||||||
|
active: row.active,
|
||||||
|
})));
|
||||||
|
break;
|
||||||
|
case "pathogens":
|
||||||
|
response = await apiPost<EditableRow[]>("/admin/pathogens", rows.map((row) => ({
|
||||||
|
id: row.id || null,
|
||||||
|
code: row.code || null,
|
||||||
|
name: row.name,
|
||||||
|
kind: row.kind,
|
||||||
|
active: row.active,
|
||||||
|
})));
|
||||||
|
break;
|
||||||
|
case "antibiotics":
|
||||||
|
response = await apiPost<EditableRow[]>("/admin/antibiotics", rows.map((row) => ({
|
||||||
|
id: row.id || null,
|
||||||
|
code: row.code || null,
|
||||||
|
name: row.name,
|
||||||
|
active: row.active,
|
||||||
|
})));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setDatasets((current) => (current ? { ...current, [selectedDataset]: response } : current));
|
||||||
|
setMessage("Aenderungen gespeichert.");
|
||||||
|
} catch (saveError) {
|
||||||
|
setMessage((saveError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Verwaltung</p>
|
||||||
|
<h3>Stammdaten direkt pflegen</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte
|
||||||
|
Satz inaktiv sichtbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div className={message.includes("gespeichert") ? "alert alert--success" : "alert alert--error"}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Datensatz</p>
|
||||||
|
<h3>{DATASET_LABELS[selectedDataset]}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="choice-row">
|
||||||
|
{(Object.keys(DATASET_LABELS) as DatasetKey[]).map((dataset) => (
|
||||||
|
<button
|
||||||
|
key={dataset}
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${selectedDataset === dataset ? "is-selected" : ""}`}
|
||||||
|
onClick={() => setSelectedDataset(dataset)}
|
||||||
|
>
|
||||||
|
{DATASET_LABELS[dataset]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
{selectedDataset === "farmers" ? <th>E-Mail</th> : null}
|
||||||
|
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
|
||||||
|
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
|
||||||
|
{selectedDataset === "pathogens" ? <th>Typ</th> : null}
|
||||||
|
<th>Aktiv</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={`${row.id || "new"}-${index}`}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
value={row.name}
|
||||||
|
onChange={(event) => updateRow(index, { name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{selectedDataset === "farmers" ? (
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
value={row.email ?? ""}
|
||||||
|
onChange={(event) => updateRow(index, { email: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
{selectedDataset === "medications" ? (
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={row.category}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateRow(index, { category: event.target.value as MedicationCategory })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="IN_UDDER">ins Euter</option>
|
||||||
|
<option value="SYSTEMIC_ANTIBIOTIC">systemisch Antibiotika</option>
|
||||||
|
<option value="SYSTEMIC_PAIN">systemisch Schmerzmittel</option>
|
||||||
|
<option value="DRY_SEALER">Versiegler</option>
|
||||||
|
<option value="DRY_ANTIBIOTIC">TS Antibiotika</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? (
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
value={row.code ?? ""}
|
||||||
|
onChange={(event) => updateRow(index, { code: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
{selectedDataset === "pathogens" ? (
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={row.kind}
|
||||||
|
onChange={(event) => updateRow(index, { kind: event.target.value as PathogenKind })}
|
||||||
|
>
|
||||||
|
<option value="BACTERIAL">bakteriell</option>
|
||||||
|
<option value="NO_GROWTH">kein Wachstum</option>
|
||||||
|
<option value="CONTAMINATED">verunreinigt</option>
|
||||||
|
<option value="OTHER">sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`eye-button ${row.active ? "is-active" : "is-inactive"}`}
|
||||||
|
onClick={() => updateRow(index, { active: !row.active })}
|
||||||
|
>
|
||||||
|
{row.active ? "sichtbar" : "inaktiv"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-actions page-actions--space-between">
|
||||||
|
<button type="button" className="secondary-button" onClick={addRow}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="accent-button" onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
{saving ? "Speichern ..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
frontend/src/pages/AnamnesisPage.tsx
Normal file
227
frontend/src/pages/AnamnesisPage.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { apiGet, apiPut } from "../lib/api";
|
||||||
|
import type { ActiveCatalogSummary, QuarterKey, QuarterView, SampleDetail } from "../lib/types";
|
||||||
|
|
||||||
|
type QuarterFormState = {
|
||||||
|
pathogenBusinessKey: string;
|
||||||
|
customPathogenName: string;
|
||||||
|
cellCount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function quarterStateFromSample(sample: SampleDetail) {
|
||||||
|
return sample.quarters.reduce<Record<string, QuarterFormState>>((accumulator, quarter) => {
|
||||||
|
accumulator[quarter.quarterKey] = {
|
||||||
|
pathogenBusinessKey: quarter.pathogenBusinessKey ?? "",
|
||||||
|
customPathogenName: quarter.customPathogenName ?? "",
|
||||||
|
cellCount: quarter.cellCount ? String(quarter.cellCount) : "",
|
||||||
|
};
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnamnesisPage() {
|
||||||
|
const { sampleId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
|
||||||
|
const [sample, setSample] = useState<SampleDetail | null>(null);
|
||||||
|
const [quarterStates, setQuarterStates] = useState<Record<string, QuarterFormState>>({});
|
||||||
|
const [activeQuarter, setActiveQuarter] = useState<QuarterKey | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
if (!sampleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [catalogResponse, sampleResponse] = await Promise.all([
|
||||||
|
apiGet<ActiveCatalogSummary>("/catalogs/summary"),
|
||||||
|
apiGet<SampleDetail>(`/samples/${sampleId}`),
|
||||||
|
]);
|
||||||
|
setCatalogs(catalogResponse);
|
||||||
|
setSample(sampleResponse);
|
||||||
|
setQuarterStates(quarterStateFromSample(sampleResponse));
|
||||||
|
setActiveQuarter(sampleResponse.quarters[0]?.quarterKey ?? null);
|
||||||
|
} catch (loadError) {
|
||||||
|
setMessage((loadError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [sampleId]);
|
||||||
|
|
||||||
|
const visibleQuarter = useMemo<QuarterView | null>(() => {
|
||||||
|
if (!sample) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sample.quarters.find((quarter) => quarter.quarterKey === activeQuarter) ?? sample.quarters[0] ?? null;
|
||||||
|
}, [activeQuarter, sample]);
|
||||||
|
|
||||||
|
function updateQuarter(quarterKey: QuarterKey, patch: Partial<QuarterFormState>) {
|
||||||
|
setQuarterStates((current) => ({
|
||||||
|
...current,
|
||||||
|
[quarterKey]: {
|
||||||
|
...current[quarterKey],
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!sampleId || !sample) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/anamnesis`, {
|
||||||
|
quarters: sample.quarters.map((quarter) => ({
|
||||||
|
quarterKey: quarter.quarterKey,
|
||||||
|
pathogenBusinessKey: quarterStates[quarter.quarterKey]?.pathogenBusinessKey || null,
|
||||||
|
customPathogenName: quarterStates[quarter.quarterKey]?.customPathogenName || null,
|
||||||
|
cellCount: quarterStates[quarter.quarterKey]?.cellCount
|
||||||
|
? Number(quarterStates[quarter.quarterKey]?.cellCount)
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
navigate(`/samples/${response.id}/${response.routeSegment}`);
|
||||||
|
} catch (saveError) {
|
||||||
|
setMessage((saveError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sample || !catalogs || !visibleQuarter) {
|
||||||
|
return <div className="empty-state">Anamnese wird geladen ...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = quarterStates[visibleQuarter.quarterKey] ?? {
|
||||||
|
pathogenBusinessKey: "",
|
||||||
|
customPathogenName: "",
|
||||||
|
cellCount: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Anamnese</p>
|
||||||
|
<h3>Probe {sample.sampleNumber}</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Erreger koennen ueber Schnellwahl oder Freitext erfasst werden. Bei 4/4-Proben wird
|
||||||
|
jedes relevante Viertel separat dokumentiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sample.anamnesisEditable ? null : (
|
||||||
|
<div className="alert alert--warning">
|
||||||
|
Die Anamnese ist in diesem Bearbeitungsstand nur noch lesbar.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{sample.quarters.length > 1 ? (
|
||||||
|
<section className="section-card">
|
||||||
|
<div className="tab-row">
|
||||||
|
{sample.quarters.map((quarter) => (
|
||||||
|
<button
|
||||||
|
key={quarter.quarterKey}
|
||||||
|
type="button"
|
||||||
|
className={`tab-chip ${activeQuarter === quarter.quarterKey ? "is-active" : ""}`}
|
||||||
|
onClick={() => setActiveQuarter(quarter.quarterKey)}
|
||||||
|
>
|
||||||
|
{quarter.label}
|
||||||
|
{quarter.flagged ? " ⚠" : ""}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="form-grid">
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Entnahmestelle</p>
|
||||||
|
<h3>{visibleQuarter.label}</h3>
|
||||||
|
{visibleQuarter.flagged ? (
|
||||||
|
<div className="info-chip">Auffaelliges Viertel markiert</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="pathogen-grid">
|
||||||
|
{catalogs.pathogens.map((pathogen) => (
|
||||||
|
<button
|
||||||
|
key={pathogen.businessKey}
|
||||||
|
type="button"
|
||||||
|
className={`pathogen-button ${
|
||||||
|
state.pathogenBusinessKey === pathogen.businessKey ? "is-selected" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
updateQuarter(visibleQuarter.quarterKey, {
|
||||||
|
pathogenBusinessKey: pathogen.businessKey,
|
||||||
|
customPathogenName: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!sample.anamnesisEditable}
|
||||||
|
>
|
||||||
|
<strong>{pathogen.name}</strong>
|
||||||
|
<small>{pathogen.code ?? pathogen.kind}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Erreger manuell eingeben</span>
|
||||||
|
<input
|
||||||
|
value={state.customPathogenName}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateQuarter(visibleQuarter.quarterKey, {
|
||||||
|
customPathogenName: event.target.value,
|
||||||
|
pathogenBusinessKey: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!sample.anamnesisEditable}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Begleitdaten</p>
|
||||||
|
<label className="field">
|
||||||
|
<span>Zellzahl {sample.sampleKind === "DRY_OFF" ? "(optional)" : ""}</span>
|
||||||
|
<input
|
||||||
|
value={state.cellCount}
|
||||||
|
onChange={(event) => updateQuarter(visibleQuarter.quarterKey, { cellCount: event.target.value })}
|
||||||
|
disabled={!sample.anamnesisEditable}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="info-panel">
|
||||||
|
<strong>Hinweis</strong>
|
||||||
|
<p>
|
||||||
|
Kein Wachstum oder verunreinigte Proben werden spaeter automatisch vom
|
||||||
|
Antibiogramm ausgeschlossen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="page-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="accent-button"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={saving || !sample.anamnesisEditable}
|
||||||
|
>
|
||||||
|
{saving ? "Speichern ..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
frontend/src/pages/AntibiogramPage.tsx
Normal file
219
frontend/src/pages/AntibiogramPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { apiGet, apiPut } from "../lib/api";
|
||||||
|
import type {
|
||||||
|
ActiveCatalogSummary,
|
||||||
|
QuarterKey,
|
||||||
|
SampleDetail,
|
||||||
|
SensitivityResult,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
|
type GroupState = Record<string, SensitivityResult | undefined>;
|
||||||
|
|
||||||
|
function quarterIdentity(sample: SampleDetail, quarterKey: QuarterKey) {
|
||||||
|
const quarter = sample.quarters.find((entry) => entry.quarterKey === quarterKey);
|
||||||
|
if (!quarter) {
|
||||||
|
return quarterKey;
|
||||||
|
}
|
||||||
|
return quarter.pathogenBusinessKey || quarter.customPathogenName || quarter.pathogenName || quarterKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AntibiogramPage() {
|
||||||
|
const { sampleId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
|
||||||
|
const [sample, setSample] = useState<SampleDetail | null>(null);
|
||||||
|
const [groupState, setGroupState] = useState<Record<string, GroupState>>({});
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
if (!sampleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [catalogResponse, sampleResponse] = await Promise.all([
|
||||||
|
apiGet<ActiveCatalogSummary>("/catalogs/summary"),
|
||||||
|
apiGet<SampleDetail>(`/samples/${sampleId}`),
|
||||||
|
]);
|
||||||
|
setCatalogs(catalogResponse);
|
||||||
|
setSample(sampleResponse);
|
||||||
|
|
||||||
|
const nextState: Record<string, GroupState> = {};
|
||||||
|
sampleResponse.antibiogramTargets.forEach((referenceQuarter) => {
|
||||||
|
const existingGroup =
|
||||||
|
sampleResponse.antibiograms.find((entry) => entry.quarterKey === referenceQuarter) ??
|
||||||
|
sampleResponse.antibiograms.find((entry) => entry.inheritedFromQuarter === referenceQuarter);
|
||||||
|
nextState[referenceQuarter] = {};
|
||||||
|
existingGroup?.entries.forEach((entry) => {
|
||||||
|
nextState[referenceQuarter][entry.antibioticBusinessKey] = entry.result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setGroupState(nextState);
|
||||||
|
} catch (loadError) {
|
||||||
|
setMessage((loadError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [sampleId]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!sample) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return sample.antibiogramTargets.map((referenceQuarter) => {
|
||||||
|
const identity = quarterIdentity(sample, referenceQuarter);
|
||||||
|
const reference = sample.quarters.find((quarter) => quarter.quarterKey === referenceQuarter);
|
||||||
|
const inherited = sample.quarters.filter(
|
||||||
|
(quarter) =>
|
||||||
|
quarter.quarterKey !== referenceQuarter &&
|
||||||
|
quarterIdentity(sample, quarter.quarterKey) === identity &&
|
||||||
|
quarter.requiresAntibiogram,
|
||||||
|
);
|
||||||
|
return { referenceQuarter, reference, inherited };
|
||||||
|
});
|
||||||
|
}, [sample]);
|
||||||
|
|
||||||
|
function updateResult(
|
||||||
|
referenceQuarter: QuarterKey,
|
||||||
|
antibioticBusinessKey: string,
|
||||||
|
result: SensitivityResult,
|
||||||
|
) {
|
||||||
|
setGroupState((current) => ({
|
||||||
|
...current,
|
||||||
|
[referenceQuarter]: {
|
||||||
|
...current[referenceQuarter],
|
||||||
|
[antibioticBusinessKey]:
|
||||||
|
current[referenceQuarter]?.[antibioticBusinessKey] === result ? undefined : result,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!sampleId || !sample) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/antibiogram`, {
|
||||||
|
groups: groups.map((group) => ({
|
||||||
|
referenceQuarter: group.referenceQuarter,
|
||||||
|
entries: Object.entries(groupState[group.referenceQuarter] ?? {})
|
||||||
|
.filter((entry): entry is [string, SensitivityResult] => Boolean(entry[1]))
|
||||||
|
.map(([antibioticBusinessKey, result]) => ({
|
||||||
|
antibioticBusinessKey,
|
||||||
|
result,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
navigate(`/samples/${response.id}/${response.routeSegment}`);
|
||||||
|
} catch (saveError) {
|
||||||
|
setMessage((saveError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sample || !catalogs) {
|
||||||
|
return <div className="empty-state">Antibiogramm wird geladen ...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Antibiogramm</p>
|
||||||
|
<h3>Probe {sample.sampleNumber}</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Nur Viertel mit bakteriellem Wachstum werden angezeigt. Identische Erreger werden
|
||||||
|
automatisch zusammengefasst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sample.antibiogramEditable ? null : (
|
||||||
|
<div className="alert alert--warning">
|
||||||
|
Das Antibiogramm ist in diesem Bearbeitungsstand nur noch lesbar.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!groups.length ? (
|
||||||
|
<div className="empty-state">Fuer diese Probe ist kein Antibiogramm erforderlich.</div>
|
||||||
|
) : (
|
||||||
|
groups.map((group) => (
|
||||||
|
<section key={group.referenceQuarter} className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{group.reference?.label}</p>
|
||||||
|
<h3>{group.reference?.customPathogenName || group.reference?.pathogenName || "Erreger"}</h3>
|
||||||
|
</div>
|
||||||
|
{group.inherited.length ? (
|
||||||
|
<div className="info-chip">
|
||||||
|
Gilt ebenfalls fuer {group.inherited.map((entry) => entry.label).join(", ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Antibiotikum</th>
|
||||||
|
<th>S</th>
|
||||||
|
<th>I</th>
|
||||||
|
<th>R</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{catalogs.antibiotics.map((antibiotic) => (
|
||||||
|
<tr key={antibiotic.businessKey}>
|
||||||
|
<td>
|
||||||
|
<strong>{antibiotic.name}</strong>
|
||||||
|
<small className="table-subtext">{antibiotic.code ?? "ANT"}</small>
|
||||||
|
</td>
|
||||||
|
{(["SENSITIVE", "INTERMEDIATE", "RESISTANT"] as SensitivityResult[]).map((result) => (
|
||||||
|
<td key={result}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`matrix-button ${
|
||||||
|
groupState[group.referenceQuarter]?.[antibiotic.businessKey] === result
|
||||||
|
? "is-selected"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => updateResult(group.referenceQuarter, antibiotic.businessKey, result)}
|
||||||
|
disabled={!sample.antibiogramEditable}
|
||||||
|
>
|
||||||
|
{result === "SENSITIVE" ? "S" : result === "INTERMEDIATE" ? "I" : "R"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="page-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="accent-button"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={saving || !sample.antibiogramEditable}
|
||||||
|
>
|
||||||
|
{saving ? "Speichern ..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
frontend/src/pages/HomePage.tsx
Normal file
179
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { apiGet } from "../lib/api";
|
||||||
|
import type { DashboardOverview, LookupResult } from "../lib/types";
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeForSample(sampleId: string, routeSegment: string) {
|
||||||
|
return `/samples/${sampleId}/${routeSegment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<string, string> = {
|
||||||
|
ANAMNESIS: "Anamnese",
|
||||||
|
ANTIBIOGRAM: "Antibiogramm",
|
||||||
|
THERAPY: "Therapie",
|
||||||
|
COMPLETED: "Abgeschlossen",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [dashboard, setDashboard] = useState<DashboardOverview | null>(null);
|
||||||
|
const [sampleNumber, setSampleNumber] = useState("");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const response = await apiGet<DashboardOverview>("/dashboard");
|
||||||
|
setDashboard(response);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDashboard();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleLookup(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!sampleNumber.trim()) {
|
||||||
|
setMessage("Bitte eine Probennummer eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber.trim()}`);
|
||||||
|
if (!response.found || !response.sampleId || !response.routeSegment) {
|
||||||
|
setMessage(response.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(null);
|
||||||
|
navigate(routeForSample(response.sampleId, response.routeSegment));
|
||||||
|
} catch (lookupError) {
|
||||||
|
setMessage((lookupError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="hero-card">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Startseite</p>
|
||||||
|
<h3>Bearbeitungsstand sofort finden</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Eine bekannte Probennummer oeffnet direkt den passenden Arbeitsschritt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="hero-card__form" onSubmit={handleLookup}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Nummer</span>
|
||||||
|
<input
|
||||||
|
value={sampleNumber}
|
||||||
|
onChange={(event) => setSampleNumber(event.target.value)}
|
||||||
|
placeholder="z. B. 100203"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="accent-button">
|
||||||
|
Probe oeffnen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary-button" onClick={() => navigate("/samples/new")}>
|
||||||
|
Neuanlage einer Probe
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="metrics-grid">
|
||||||
|
<article className="metric-card">
|
||||||
|
<span className="metric-card__label">Naechste Nummer</span>
|
||||||
|
<strong>{dashboard?.nextSampleNumber ?? "..."}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric-card">
|
||||||
|
<span className="metric-card__label">Offene Proben</span>
|
||||||
|
<strong>{dashboard?.openSamples ?? "..."}</strong>
|
||||||
|
</article>
|
||||||
|
<article className="metric-card">
|
||||||
|
<span className="metric-card__label">Heute abgeschlossen</span>
|
||||||
|
<strong>{dashboard?.completedToday ?? "..."}</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Arbeitsvorrat</p>
|
||||||
|
<h3>Zuletzt bearbeitete Proben</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Dashboard wird geladen ...</div>
|
||||||
|
) : dashboard?.recentSamples.length ? (
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Probe</th>
|
||||||
|
<th>Landwirt</th>
|
||||||
|
<th>Kuh</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktualisiert</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dashboard.recentSamples.map((sample) => (
|
||||||
|
<tr key={sample.id}>
|
||||||
|
<td>{sample.sampleNumber}</td>
|
||||||
|
<td>{sample.farmerName}</td>
|
||||||
|
<td>{sample.cowLabel}</td>
|
||||||
|
<td>{sample.sampleKind === "DRY_OFF" ? "Trockensteller" : "Laktation"}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-pill status-pill--${sample.currentStep.toLowerCase()}`}>
|
||||||
|
{STEP_LABELS[sample.currentStep]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(sample.updatedAt)}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="table-link"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
routeForSample(
|
||||||
|
sample.id,
|
||||||
|
sample.currentStep === "ANTIBIOGRAM"
|
||||||
|
? "antibiogram"
|
||||||
|
: sample.currentStep === "THERAPY" || sample.currentStep === "COMPLETED"
|
||||||
|
? "therapy"
|
||||||
|
: "anamnesis",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Oeffnen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">Noch keine Proben vorhanden.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
frontend/src/pages/LoginPage.tsx
Normal file
255
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { apiGet, apiPost } from "../lib/api";
|
||||||
|
import { useSession } from "../lib/session";
|
||||||
|
import type { UserOption } from "../lib/types";
|
||||||
|
|
||||||
|
type FeedbackState =
|
||||||
|
| { type: "error"; text: string }
|
||||||
|
| { type: "success"; text: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [users, setUsers] = useState<UserOption[]>([]);
|
||||||
|
const [manualCode, setManualCode] = useState("");
|
||||||
|
const [identifier, setIdentifier] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [registration, setRegistration] = useState({
|
||||||
|
companyName: "",
|
||||||
|
address: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackState>(null);
|
||||||
|
const { setUser } = useSession();
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
setLoading(true);
|
||||||
|
setFeedback(null);
|
||||||
|
try {
|
||||||
|
const response = await apiGet<UserOption[]>("/session/users");
|
||||||
|
setUsers(response);
|
||||||
|
} catch (loadError) {
|
||||||
|
setFeedback({ type: "error", text: (loadError as Error).message });
|
||||||
|
setUsers([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCodeLogin(code: string) {
|
||||||
|
if (!code.trim()) {
|
||||||
|
setFeedback({ type: "error", text: "Bitte ein Benutzerkuerzel eingeben oder auswaehlen." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiPost<UserOption>("/session/login", { code });
|
||||||
|
setUser(response);
|
||||||
|
} catch (loginError) {
|
||||||
|
setFeedback({ type: "error", text: (loginError as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await apiPost<UserOption>("/session/password-login", {
|
||||||
|
identifier,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setUser(response);
|
||||||
|
} catch (loginError) {
|
||||||
|
setFeedback({ type: "error", text: (loginError as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await apiPost<UserOption>("/session/register", registration);
|
||||||
|
setFeedback({
|
||||||
|
type: "success",
|
||||||
|
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`,
|
||||||
|
});
|
||||||
|
setUser(response);
|
||||||
|
} catch (registrationError) {
|
||||||
|
setFeedback({ type: "error", text: (registrationError as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<section className="login-hero">
|
||||||
|
<div className="login-hero__copy">
|
||||||
|
<p className="eyebrow">MUH-App</p>
|
||||||
|
<h1>Moderne Steuerung fuer Milchproben und Therapien.</h1>
|
||||||
|
<p className="hero-text">
|
||||||
|
Fokus auf klare Arbeitsablaeufe, schnelle Probenbearbeitung und ein Portal
|
||||||
|
fuer Verwaltung, Berichtsdruck und Versandstatus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-hero__panel">
|
||||||
|
<div className="panel-glow" />
|
||||||
|
<p className="eyebrow">Zugang</p>
|
||||||
|
<h2>Anmelden oder registrieren</h2>
|
||||||
|
<p className="muted-text">
|
||||||
|
Weiterhin moeglich: Direktanmeldung per Benutzerkuerzel. Neu: Login mit
|
||||||
|
E-Mail/Benutzername und Passwort.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{feedback ? (
|
||||||
|
<div className={`alert ${feedback.type === "success" ? "alert--success" : "alert--error"}`}>
|
||||||
|
{feedback.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="login-panel__section">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Schnelllogin</p>
|
||||||
|
<h3>Benutzerkuerzel</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="secondary-button" onClick={() => void loadUsers()}>
|
||||||
|
Neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Benutzer werden geladen ...</div>
|
||||||
|
) : users.length ? (
|
||||||
|
<div className="user-grid">
|
||||||
|
{users.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
className="user-card"
|
||||||
|
onClick={() => void handleCodeLogin(user.code)}
|
||||||
|
>
|
||||||
|
<span className="user-card__code">{user.code}</span>
|
||||||
|
<strong>{user.displayName}</strong>
|
||||||
|
<small>
|
||||||
|
{user.role === "ADMIN"
|
||||||
|
? "Admin"
|
||||||
|
: user.role === "CUSTOMER"
|
||||||
|
? "Kunde"
|
||||||
|
: "App"}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="page-stack">
|
||||||
|
<div className="empty-state">
|
||||||
|
Es wurden keine aktiven Benutzer geladen. Das Kuersel kann trotzdem direkt
|
||||||
|
eingegeben werden.
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>Benutzerkuerzel</span>
|
||||||
|
<input
|
||||||
|
value={manualCode}
|
||||||
|
onChange={(event) => setManualCode(event.target.value.toUpperCase())}
|
||||||
|
placeholder="z. B. SV"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="accent-button"
|
||||||
|
onClick={() => void handleCodeLogin(manualCode)}
|
||||||
|
>
|
||||||
|
Mit Kuerzel anmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider-label">oder mit Passwort</div>
|
||||||
|
|
||||||
|
<div className="auth-grid">
|
||||||
|
<form className="login-panel__section" onSubmit={handlePasswordLogin}>
|
||||||
|
<p className="eyebrow">Login</p>
|
||||||
|
<h3>E-Mail oder Benutzername</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>E-Mail / Benutzername</span>
|
||||||
|
<input
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
placeholder="z. B. admin oder name@hof.de"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button type="submit" className="accent-button">
|
||||||
|
Mit Passwort anmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form className="login-panel__section" onSubmit={handleRegister}>
|
||||||
|
<p className="eyebrow">Kundenregistrierung</p>
|
||||||
|
<h3>Neues Kundenkonto anlegen</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>Firmenname</span>
|
||||||
|
<input
|
||||||
|
value={registration.companyName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRegistration((current) => ({ ...current, companyName: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="z. B. Muster Agrar GmbH"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Adresse</span>
|
||||||
|
<textarea
|
||||||
|
value={registration.address}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRegistration((current) => ({ ...current, address: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Strasse, Hausnummer, PLZ Ort"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={registration.email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRegistration((current) => ({ ...current, email: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={registration.password}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRegistration((current) => ({ ...current, password: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button type="submit" className="accent-button">
|
||||||
|
Registrieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
429
frontend/src/pages/PortalPage.tsx
Normal file
429
frontend/src/pages/PortalPage.tsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
|
import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api";
|
||||||
|
import type { PortalSnapshot, UserRole } from "../lib/types";
|
||||||
|
|
||||||
|
function formatDate(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortalPage() {
|
||||||
|
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null);
|
||||||
|
const [selectedFarmer, setSelectedFarmer] = useState("");
|
||||||
|
const [farmerQuery, setFarmerQuery] = useState("");
|
||||||
|
const [cowQuery, setCowQuery] = useState("");
|
||||||
|
const [sampleNumberQuery, setSampleNumberQuery] = useState("");
|
||||||
|
const [dateQuery, setDateQuery] = useState("");
|
||||||
|
const [selectedReports, setSelectedReports] = useState<string[]>([]);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [userForm, setUserForm] = useState({
|
||||||
|
code: "",
|
||||||
|
displayName: "",
|
||||||
|
email: "",
|
||||||
|
portalLogin: "",
|
||||||
|
password: "",
|
||||||
|
role: "APP" as UserRole,
|
||||||
|
});
|
||||||
|
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
async function loadSnapshot() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedFarmer) {
|
||||||
|
params.set("farmerBusinessKey", selectedFarmer);
|
||||||
|
}
|
||||||
|
if (farmerQuery) {
|
||||||
|
params.set("farmerQuery", farmerQuery);
|
||||||
|
}
|
||||||
|
if (cowQuery) {
|
||||||
|
params.set("cowQuery", cowQuery);
|
||||||
|
}
|
||||||
|
if (sampleNumberQuery) {
|
||||||
|
params.set("sampleNumber", sampleNumberQuery);
|
||||||
|
}
|
||||||
|
if (dateQuery) {
|
||||||
|
params.set("date", dateQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiGet<PortalSnapshot>(`/portal/snapshot?${params.toString()}`);
|
||||||
|
setSnapshot(response);
|
||||||
|
setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSnapshot();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reportCount = useMemo(() => selectedReports.length, [selectedReports]);
|
||||||
|
|
||||||
|
function toggleReport(sampleId: string) {
|
||||||
|
setSelectedReports((current) =>
|
||||||
|
current.includes(sampleId)
|
||||||
|
? current.filter((entry) => entry !== sampleId)
|
||||||
|
: [...current, sampleId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(event?: FormEvent) {
|
||||||
|
event?.preventDefault();
|
||||||
|
try {
|
||||||
|
setMessage(null);
|
||||||
|
await loadSnapshot();
|
||||||
|
} catch (loadError) {
|
||||||
|
setMessage((loadError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDispatchReports() {
|
||||||
|
try {
|
||||||
|
const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", {
|
||||||
|
sampleIds: selectedReports,
|
||||||
|
});
|
||||||
|
setMessage(
|
||||||
|
response.mailDeliveryActive
|
||||||
|
? "Berichte wurden versendet."
|
||||||
|
: "Berichte wurden als versendet markiert. Fuer echten Mailversand fehlt noch SMTP-Konfiguration.",
|
||||||
|
);
|
||||||
|
await loadSnapshot();
|
||||||
|
} catch (dispatchError) {
|
||||||
|
setMessage((dispatchError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await apiPost("/portal/users", {
|
||||||
|
...userForm,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
setUserForm({
|
||||||
|
code: "",
|
||||||
|
displayName: "",
|
||||||
|
email: "",
|
||||||
|
portalLogin: "",
|
||||||
|
password: "",
|
||||||
|
role: "APP",
|
||||||
|
});
|
||||||
|
setMessage("Benutzer gespeichert.");
|
||||||
|
await loadSnapshot();
|
||||||
|
} catch (userError) {
|
||||||
|
setMessage((userError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUser(userId: string) {
|
||||||
|
try {
|
||||||
|
await apiDelete(`/portal/users/${userId}`);
|
||||||
|
setMessage("Benutzer geloescht.");
|
||||||
|
await loadSnapshot();
|
||||||
|
} catch (deleteError) {
|
||||||
|
setMessage((deleteError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordChange(userId: string) {
|
||||||
|
try {
|
||||||
|
await apiPost(`/portal/users/${userId}/password`, {
|
||||||
|
password: passwordDrafts[userId],
|
||||||
|
});
|
||||||
|
setPasswordDrafts((current) => ({ ...current, [userId]: "" }));
|
||||||
|
setMessage("Passwort aktualisiert.");
|
||||||
|
} catch (passwordError) {
|
||||||
|
setMessage((passwordError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleBlocked(sampleId: string, blocked: boolean) {
|
||||||
|
try {
|
||||||
|
await apiPatch(`/portal/reports/${sampleId}/block`, { blocked });
|
||||||
|
await loadSnapshot();
|
||||||
|
} catch (blockError) {
|
||||||
|
setMessage((blockError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return <div className="empty-state">Portal wird geladen ...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">MUH-Portal</p>
|
||||||
|
<h3>Benutzer, Berichtversand und Schnellsuche</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Das Portal kombiniert Verwaltungsfunktionen mit dem Versandstatus aller
|
||||||
|
abgeschlossenen Proben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div className={message.includes("gespeichert") || message.includes("versendet") || message.includes("aktualisiert") || message.includes("geloescht") ? "alert alert--success" : "alert alert--error"}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="portal-grid">
|
||||||
|
<article className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Bericht-Versand</p>
|
||||||
|
<h3>Versandbereite Proben</h3>
|
||||||
|
</div>
|
||||||
|
<div className="info-chip">{reportCount} markiert</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="check-list">
|
||||||
|
{snapshot.reportCandidates.map((candidate) => (
|
||||||
|
<label key={candidate.sampleId} className="check-list__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedReports.includes(candidate.sampleId)}
|
||||||
|
onChange={() => toggleReport(candidate.sampleId)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Probe {candidate.sampleNumber} | {candidate.farmerName} | {candidate.farmerEmail}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-actions">
|
||||||
|
<button type="button" className="accent-button" onClick={() => void handleDispatchReports()}>
|
||||||
|
Markierte Mails versenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Benutzerverwaltung</p>
|
||||||
|
<form className="field-grid" onSubmit={handleCreateUser}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Kuerzel</span>
|
||||||
|
<input
|
||||||
|
value={userForm.code}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
value={userForm.displayName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setUserForm((current) => ({ ...current, displayName: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Login</span>
|
||||||
|
<input
|
||||||
|
value={userForm.portalLogin}
|
||||||
|
onChange={(event) =>
|
||||||
|
setUserForm((current) => ({ ...current, portalLogin: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={userForm.email}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Passwort</span>
|
||||||
|
<input
|
||||||
|
value={userForm.password}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Rolle</span>
|
||||||
|
<select
|
||||||
|
value={userForm.role}
|
||||||
|
onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))}
|
||||||
|
>
|
||||||
|
<option value="APP">APP</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button type="submit" className="accent-button">
|
||||||
|
Benutzer anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kuerzel</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>Login</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Passwort</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{snapshot.users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>{user.code}</td>
|
||||||
|
<td>{user.displayName}</td>
|
||||||
|
<td>{user.email ?? "-"}</td>
|
||||||
|
<td>{user.portalLogin ?? "-"}</td>
|
||||||
|
<td>{user.role}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordDrafts[user.id] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="table-actions">
|
||||||
|
<button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-card">
|
||||||
|
<form className="field-grid" onSubmit={handleSearch}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Landwirt suchen</span>
|
||||||
|
<input value={farmerQuery} onChange={(event) => setFarmerQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Gefundener Landwirt</span>
|
||||||
|
<select value={selectedFarmer} onChange={(event) => setSelectedFarmer(event.target.value)}>
|
||||||
|
<option value="">alle / noch keiner</option>
|
||||||
|
{snapshot.farmers.map((farmer) => (
|
||||||
|
<option key={farmer.businessKey} value={farmer.businessKey}>
|
||||||
|
{farmer.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Kuh</span>
|
||||||
|
<input value={cowQuery} onChange={(event) => setCowQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Probe-Nr.</span>
|
||||||
|
<input value={sampleNumberQuery} onChange={(event) => setSampleNumberQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Datum</span>
|
||||||
|
<input type="date" value={dateQuery} onChange={(event) => setDateQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="page-actions page-actions--align-end">
|
||||||
|
<button type="submit" className="accent-button">
|
||||||
|
Suche starten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFarmer("");
|
||||||
|
setFarmerQuery("");
|
||||||
|
setCowQuery("");
|
||||||
|
setSampleNumberQuery("");
|
||||||
|
setDateQuery("");
|
||||||
|
void handleSearch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zuruecksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Suchergebnis</p>
|
||||||
|
<h3>Gefundene Milchproben</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Probe</th>
|
||||||
|
<th>Anlage</th>
|
||||||
|
<th>Landwirt</th>
|
||||||
|
<th>Kuh</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Interne Bemerkung</th>
|
||||||
|
<th>PDF</th>
|
||||||
|
<th>Versand</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{snapshot.samples.map((sample) => (
|
||||||
|
<tr key={sample.sampleId}>
|
||||||
|
<td>{sample.sampleNumber}</td>
|
||||||
|
<td>{formatDate(sample.createdAt)}</td>
|
||||||
|
<td>{sample.farmerName}</td>
|
||||||
|
<td>{sample.cowNumber}{sample.cowName ? ` / ${sample.cowName}` : ""}</td>
|
||||||
|
<td>{sample.sampleKindLabel === "DRY_OFF" ? "Trockensteller" : "Milchprobe"}</td>
|
||||||
|
<td>{sample.internalNote ?? "-"}</td>
|
||||||
|
<td>
|
||||||
|
{sample.completed ? (
|
||||||
|
<a className="table-link" href={pdfUrl(sample.sampleId)} target="_blank" rel="noreferrer">
|
||||||
|
PDF
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="muted-text">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="table-actions">
|
||||||
|
<span className={`status-pill ${sample.reportSent ? "status-pill--completed" : "status-pill--therapy"}`}>
|
||||||
|
{sample.reportSent ? "versendet" : "offen"}
|
||||||
|
</span>
|
||||||
|
{sample.completed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="table-link"
|
||||||
|
onClick={() => void handleToggleBlocked(sample.sampleId, !sample.reportBlocked)}
|
||||||
|
>
|
||||||
|
{sample.reportBlocked ? "freigeben" : "blockieren"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
frontend/src/pages/SampleRegistrationPage.tsx
Normal file
264
frontend/src/pages/SampleRegistrationPage.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { apiGet, apiPost, apiPut } from "../lib/api";
|
||||||
|
import { useSession } from "../lib/session";
|
||||||
|
import type {
|
||||||
|
ActiveCatalogSummary,
|
||||||
|
DashboardOverview,
|
||||||
|
QuarterKey,
|
||||||
|
SampleDetail,
|
||||||
|
SampleKind,
|
||||||
|
SamplingMode,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
|
const QUARTERS: { key: QuarterKey; label: string }[] = [
|
||||||
|
{ key: "LEFT_FRONT", label: "Vorne links" },
|
||||||
|
{ key: "RIGHT_FRONT", label: "Vorne rechts" },
|
||||||
|
{ key: "LEFT_REAR", label: "Hinten links" },
|
||||||
|
{ key: "RIGHT_REAR", label: "Hinten rechts" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SampleRegistrationPage() {
|
||||||
|
const { sampleId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
|
||||||
|
const [sampleNumber, setSampleNumber] = useState<number | null>(null);
|
||||||
|
const [editable, setEditable] = useState(true);
|
||||||
|
const [farmerBusinessKey, setFarmerBusinessKey] = useState("");
|
||||||
|
const [cowNumber, setCowNumber] = useState("");
|
||||||
|
const [cowName, setCowName] = useState("");
|
||||||
|
const [sampleKind, setSampleKind] = useState<SampleKind>("LACTATION");
|
||||||
|
const [samplingMode, setSamplingMode] = useState<SamplingMode>("SINGLE_SITE");
|
||||||
|
const [flaggedQuarters, setFlaggedQuarters] = useState<QuarterKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const catalogResponse = await apiGet<ActiveCatalogSummary>("/catalogs/summary");
|
||||||
|
setCatalogs(catalogResponse);
|
||||||
|
|
||||||
|
if (sampleId) {
|
||||||
|
const sample = await apiGet<SampleDetail>(`/samples/${sampleId}`);
|
||||||
|
setSampleNumber(sample.sampleNumber);
|
||||||
|
setEditable(sample.registrationEditable);
|
||||||
|
setFarmerBusinessKey(sample.farmerBusinessKey);
|
||||||
|
setCowNumber(sample.cowNumber);
|
||||||
|
setCowName(sample.cowName ?? "");
|
||||||
|
setSampleKind(sample.sampleKind);
|
||||||
|
setSamplingMode(sample.samplingMode);
|
||||||
|
setFlaggedQuarters(
|
||||||
|
sample.quarters.filter((quarter) => quarter.flagged).map((quarter) => quarter.quarterKey),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const dashboard = await apiGet<DashboardOverview>("/dashboard");
|
||||||
|
setSampleNumber(dashboard.nextSampleNumber);
|
||||||
|
setFarmerBusinessKey(catalogResponse.farmers[0]?.businessKey ?? "");
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
setMessage((loadError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [sampleId]);
|
||||||
|
|
||||||
|
function toggleFlaggedQuarter(quarterKey: QuarterKey) {
|
||||||
|
setFlaggedQuarters((current) =>
|
||||||
|
current.includes(quarterKey)
|
||||||
|
? current.filter((entry) => entry !== quarterKey)
|
||||||
|
: [...current, quarterKey],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!farmerBusinessKey || !cowNumber.trim()) {
|
||||||
|
setMessage("Landwirt und Kuh-Nummer sind erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
farmerBusinessKey,
|
||||||
|
cowNumber,
|
||||||
|
cowName,
|
||||||
|
sampleKind,
|
||||||
|
samplingMode,
|
||||||
|
flaggedQuarters,
|
||||||
|
userCode: user.code,
|
||||||
|
userDisplayName: user.displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = sampleId
|
||||||
|
? await apiPut<SampleDetail>(`/samples/${sampleId}/registration`, payload)
|
||||||
|
: await apiPost<SampleDetail>("/samples", payload);
|
||||||
|
navigate(`/samples/${response.id}/${response.routeSegment}`);
|
||||||
|
} catch (saveError) {
|
||||||
|
setMessage((saveError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="empty-state">Probe wird vorbereitet ...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="page-stack" onSubmit={handleSubmit}>
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Neuanlage</p>
|
||||||
|
<h3>Probe {sampleNumber ?? "..."}</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich ueber den
|
||||||
|
Schalter TS markieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editable ? (
|
||||||
|
<div className="alert alert--warning">
|
||||||
|
Diese Probe ist bereits weiter im Ablauf. Stammdaten sind nicht mehr editierbar.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="form-grid">
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Stammdaten</p>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Landwirt</span>
|
||||||
|
<select
|
||||||
|
value={farmerBusinessKey}
|
||||||
|
onChange={(event) => setFarmerBusinessKey(event.target.value)}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
{catalogs?.farmers.map((farmer) => (
|
||||||
|
<option key={farmer.businessKey} value={farmer.businessKey}>
|
||||||
|
{farmer.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Kuh-Nummer</span>
|
||||||
|
<input
|
||||||
|
value={cowNumber}
|
||||||
|
onChange={(event) => setCowNumber(event.target.value)}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Kuh-Name</span>
|
||||||
|
<input
|
||||||
|
value={cowName}
|
||||||
|
onChange={(event) => setCowName(event.target.value)}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Probentyp</p>
|
||||||
|
<div className="choice-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${sampleKind === "LACTATION" ? "is-selected" : ""}`}
|
||||||
|
onClick={() => setSampleKind("LACTATION")}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
Laktationsprobe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${sampleKind === "DRY_OFF" ? "is-selected" : ""}`}
|
||||||
|
onClick={() => setSampleKind("DRY_OFF")}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
TS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="eyebrow section-card__spacer">Entnahmestelle</p>
|
||||||
|
<div className="choice-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${samplingMode === "SINGLE_SITE" ? "is-selected" : ""}`}
|
||||||
|
onClick={() => setSamplingMode("SINGLE_SITE")}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
Einzelprobe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${samplingMode === "FOUR_QUARTER" ? "is-selected" : ""}`}
|
||||||
|
onClick={() => setSamplingMode("FOUR_QUARTER")}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
4/4 Probe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${samplingMode === "UNKNOWN_SITE" ? "is-selected" : ""}`}
|
||||||
|
onClick={() => setSamplingMode("UNKNOWN_SITE")}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
Unbek.
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{samplingMode === "FOUR_QUARTER" ? (
|
||||||
|
<section className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Auffaellige Viertel</p>
|
||||||
|
<h3>Viertel markieren</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quarter-grid">
|
||||||
|
{QUARTERS.map((quarter) => (
|
||||||
|
<button
|
||||||
|
key={quarter.key}
|
||||||
|
type="button"
|
||||||
|
className={`quarter-tile ${flaggedQuarters.includes(quarter.key) ? "is-flagged" : ""}`}
|
||||||
|
onClick={() => toggleFlaggedQuarter(quarter.key)}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
<span>{quarter.label}</span>
|
||||||
|
<strong>{flaggedQuarters.includes(quarter.key) ? "⚠" : "OK"}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="page-actions">
|
||||||
|
<button type="submit" className="accent-button" disabled={saving || !editable}>
|
||||||
|
{saving ? "Speichern ..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
frontend/src/pages/TherapyPage.tsx
Normal file
284
frontend/src/pages/TherapyPage.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { apiGet, apiPut } from "../lib/api";
|
||||||
|
import type {
|
||||||
|
ActiveCatalogSummary,
|
||||||
|
MedicationCategory,
|
||||||
|
SampleDetail,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
|
function medicationOptions(catalogs: ActiveCatalogSummary, category: MedicationCategory) {
|
||||||
|
return catalogs.medications.filter((medication) => medication.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TherapyPage() {
|
||||||
|
const { sampleId } = useParams();
|
||||||
|
|
||||||
|
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
|
||||||
|
const [sample, setSample] = useState<SampleDetail | null>(null);
|
||||||
|
const [continueStarted, setContinueStarted] = useState(false);
|
||||||
|
const [switchTherapy, setSwitchTherapy] = useState(false);
|
||||||
|
const [inUdderMedicationKeys, setInUdderMedicationKeys] = useState<string[]>([]);
|
||||||
|
const [inUdderOther, setInUdderOther] = useState("");
|
||||||
|
const [systemicMedicationKeys, setSystemicMedicationKeys] = useState<string[]>([]);
|
||||||
|
const [systemicOther, setSystemicOther] = useState("");
|
||||||
|
const [drySealerKeys, setDrySealerKeys] = useState<string[]>([]);
|
||||||
|
const [dryAntibioticKeys, setDryAntibioticKeys] = useState<string[]>([]);
|
||||||
|
const [farmerNote, setFarmerNote] = useState("");
|
||||||
|
const [internalNote, setInternalNote] = useState("");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
if (!sampleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [catalogResponse, sampleResponse] = await Promise.all([
|
||||||
|
apiGet<ActiveCatalogSummary>("/catalogs/summary"),
|
||||||
|
apiGet<SampleDetail>(`/samples/${sampleId}`),
|
||||||
|
]);
|
||||||
|
setCatalogs(catalogResponse);
|
||||||
|
setSample(sampleResponse);
|
||||||
|
setContinueStarted(sampleResponse.therapy?.continueStarted ?? false);
|
||||||
|
setSwitchTherapy(sampleResponse.therapy?.switchTherapy ?? false);
|
||||||
|
setInUdderMedicationKeys(sampleResponse.therapy?.inUdderMedicationKeys ?? []);
|
||||||
|
setInUdderOther(sampleResponse.therapy?.inUdderOther ?? "");
|
||||||
|
setSystemicMedicationKeys(sampleResponse.therapy?.systemicMedicationKeys ?? []);
|
||||||
|
setSystemicOther(sampleResponse.therapy?.systemicOther ?? "");
|
||||||
|
setDrySealerKeys(sampleResponse.therapy?.drySealerKeys ?? []);
|
||||||
|
setDryAntibioticKeys(sampleResponse.therapy?.dryAntibioticKeys ?? []);
|
||||||
|
setFarmerNote(sampleResponse.therapy?.farmerNote ?? "");
|
||||||
|
setInternalNote(sampleResponse.therapy?.internalNote ?? "");
|
||||||
|
} catch (loadError) {
|
||||||
|
setMessage((loadError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [sampleId]);
|
||||||
|
|
||||||
|
const therapyLocked = useMemo(() => sample?.completed ?? false, [sample]);
|
||||||
|
|
||||||
|
function toggleSelection(list: string[], value: string, setter: (next: string[]) => void) {
|
||||||
|
setter(list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!sampleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/therapy`, {
|
||||||
|
continueStarted,
|
||||||
|
switchTherapy,
|
||||||
|
inUdderMedicationKeys,
|
||||||
|
inUdderOther,
|
||||||
|
systemicMedicationKeys,
|
||||||
|
systemicOther,
|
||||||
|
drySealerKeys,
|
||||||
|
dryAntibioticKeys,
|
||||||
|
farmerNote,
|
||||||
|
internalNote,
|
||||||
|
});
|
||||||
|
setSample(response);
|
||||||
|
setMessage(response.completed ? "Probe gespeichert und abgeschlossen." : "Aenderung gespeichert.");
|
||||||
|
} catch (saveError) {
|
||||||
|
setMessage((saveError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sample || !catalogs) {
|
||||||
|
return <div className="empty-state">Therapieempfehlung wird geladen ...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Therapieempfehlung</p>
|
||||||
|
<h3>Probe {sample.sampleNumber}</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Laktations- und Trockenstellerproben verwenden unterschiedliche Medikationsgruppen.
|
||||||
|
Bei abgeschlossenen Proben bleibt nur die interne Bemerkung editierbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sample.completed ? (
|
||||||
|
<div className="alert alert--warning">
|
||||||
|
Probe abgeschlossen. Nur das Feld Interne Bemerkung kann noch angepasst werden.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message ? <div className={message.includes("gespeichert") ? "alert alert--success" : "alert alert--error"}>{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{sample.sampleKind === "LACTATION" ? (
|
||||||
|
<section className="form-grid">
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Empfehlung / Therapie</p>
|
||||||
|
<div className="choice-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${continueStarted ? "is-selected" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setContinueStarted((current) => !current);
|
||||||
|
if (!continueStarted) {
|
||||||
|
setSwitchTherapy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
>
|
||||||
|
weiter wie begonnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${switchTherapy ? "is-selected" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSwitchTherapy((current) => !current);
|
||||||
|
if (!switchTherapy) {
|
||||||
|
setContinueStarted(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
>
|
||||||
|
umstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="eyebrow section-card__spacer">ins Euter</p>
|
||||||
|
<div className="choice-row choice-row--wrap">
|
||||||
|
{medicationOptions(catalogs, "IN_UDDER").map((medication) => (
|
||||||
|
<button
|
||||||
|
key={medication.businessKey}
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${inUdderMedicationKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
toggleSelection(inUdderMedicationKeys, medication.businessKey, setInUdderMedicationKeys)
|
||||||
|
}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
>
|
||||||
|
{medication.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Sonstiges</span>
|
||||||
|
<textarea
|
||||||
|
value={inUdderOther}
|
||||||
|
onChange={(event) => setInUdderOther(event.target.value)}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Systemisch</p>
|
||||||
|
<div className="choice-row choice-row--wrap">
|
||||||
|
{[...medicationOptions(catalogs, "SYSTEMIC_PAIN"), ...medicationOptions(catalogs, "SYSTEMIC_ANTIBIOTIC")].map(
|
||||||
|
(medication) => (
|
||||||
|
<button
|
||||||
|
key={medication.businessKey}
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${systemicMedicationKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
toggleSelection(
|
||||||
|
systemicMedicationKeys,
|
||||||
|
medication.businessKey,
|
||||||
|
setSystemicMedicationKeys,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
>
|
||||||
|
{medication.name}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Sonstiges</span>
|
||||||
|
<textarea
|
||||||
|
value={systemicOther}
|
||||||
|
onChange={(event) => setSystemicOther(event.target.value)}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="form-grid">
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Trockensteller</p>
|
||||||
|
<h3>Versiegler</h3>
|
||||||
|
<div className="choice-row choice-row--wrap">
|
||||||
|
{medicationOptions(catalogs, "DRY_SEALER").map((medication) => (
|
||||||
|
<button
|
||||||
|
key={medication.businessKey}
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${drySealerKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
|
||||||
|
onClick={() => toggleSelection(drySealerKeys, medication.businessKey, setDrySealerKeys)}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
>
|
||||||
|
{medication.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="section-card">
|
||||||
|
<p className="eyebrow">Trockensteller</p>
|
||||||
|
<h3>Antibiotika</h3>
|
||||||
|
<div className="choice-row choice-row--wrap">
|
||||||
|
{medicationOptions(catalogs, "DRY_ANTIBIOTIC").map((medication) => (
|
||||||
|
<button
|
||||||
|
key={medication.businessKey}
|
||||||
|
type="button"
|
||||||
|
className={`choice-chip ${dryAntibioticKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
toggleSelection(dryAntibioticKeys, medication.businessKey, setDryAntibioticKeys)
|
||||||
|
}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
>
|
||||||
|
{medication.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="form-grid">
|
||||||
|
<article className="section-card">
|
||||||
|
<label className="field">
|
||||||
|
<span>Anmerkung fuer Landwirt</span>
|
||||||
|
<textarea
|
||||||
|
value={farmerNote}
|
||||||
|
onChange={(event) => setFarmerNote(event.target.value)}
|
||||||
|
disabled={therapyLocked}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="section-card">
|
||||||
|
<label className="field">
|
||||||
|
<span>Interne Bemerkung</span>
|
||||||
|
<textarea value={internalNote} onChange={(event) => setInternalNote(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="page-actions">
|
||||||
|
<button type="button" className="accent-button" onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
{saving ? "Speichern ..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
693
frontend/src/styles/global.css
Normal file
693
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f3ece2;
|
||||||
|
--bg-deep: #d8cab6;
|
||||||
|
--surface: rgba(255, 250, 245, 0.84);
|
||||||
|
--surface-strong: #fff8f0;
|
||||||
|
--surface-contrast: #25313a;
|
||||||
|
--line: rgba(37, 49, 58, 0.12);
|
||||||
|
--text: #1d2428;
|
||||||
|
--muted: #6e766f;
|
||||||
|
--accent: #116d63;
|
||||||
|
--accent-soft: rgba(17, 109, 99, 0.12);
|
||||||
|
--accent-strong: #0d5b53;
|
||||||
|
--danger: #9d3c30;
|
||||||
|
--warning: #8a6500;
|
||||||
|
--success: #2d6a4f;
|
||||||
|
--shadow: 0 30px 80px rgba(54, 44, 27, 0.14);
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-lg: 22px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Sora", "Avenir Next", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(17, 109, 99, 0.18), transparent 32%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(201, 129, 47, 0.18), transparent 28%),
|
||||||
|
linear-gradient(135deg, var(--bg) 0%, #efe4d5 52%, var(--bg-deep) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(228px, 280px) minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28px 24px;
|
||||||
|
background: rgba(23, 34, 41, 0.92);
|
||||||
|
color: #f8f3ed;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand h1,
|
||||||
|
.topbar__headline h2,
|
||||||
|
.section-card h3,
|
||||||
|
.login-hero h1,
|
||||||
|
.login-hero h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 108px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #1d9485, #0f5b53);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 32px 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: rgba(248, 243, 237, 0.78);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.is-active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff8f0;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip--stacked {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button,
|
||||||
|
.secondary-button,
|
||||||
|
.accent-button,
|
||||||
|
.menu-toggle,
|
||||||
|
.choice-chip,
|
||||||
|
.pathogen-button,
|
||||||
|
.matrix-button,
|
||||||
|
.eye-button,
|
||||||
|
.quarter-tile,
|
||||||
|
.user-card,
|
||||||
|
.table-link,
|
||||||
|
.tab-chip {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms ease, box-shadow 150ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button,
|
||||||
|
.menu-toggle,
|
||||||
|
.secondary-button {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button:hover,
|
||||||
|
.menu-toggle:hover,
|
||||||
|
.secondary-button:hover,
|
||||||
|
.choice-chip:hover,
|
||||||
|
.pathogen-button:hover,
|
||||||
|
.matrix-button:hover,
|
||||||
|
.eye-button:hover,
|
||||||
|
.quarter-tile:hover,
|
||||||
|
.user-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-button {
|
||||||
|
padding: 13px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||||
|
color: #fcf7f1;
|
||||||
|
box-shadow: 0 18px 30px rgba(17, 109, 99, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 22px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 0 36px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.section-card,
|
||||||
|
.login-hero__panel {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.38);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card,
|
||||||
|
.hero-card {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card--hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card__form {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid,
|
||||||
|
.form-grid,
|
||||||
|
.portal-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.portal-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 22px 24px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: rgba(255, 248, 240, 0.68);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card__label,
|
||||||
|
.eyebrow,
|
||||||
|
.muted-text,
|
||||||
|
.table-subtext {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-text,
|
||||||
|
.table-subtext {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea,
|
||||||
|
.data-table input,
|
||||||
|
.data-table select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-row,
|
||||||
|
.tab-row,
|
||||||
|
.page-actions,
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-row--wrap {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-chip,
|
||||||
|
.tab-chip,
|
||||||
|
.pathogen-button,
|
||||||
|
.quarter-tile,
|
||||||
|
.user-card {
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-chip.is-selected,
|
||||||
|
.tab-chip.is-active,
|
||||||
|
.pathogen-button.is-selected,
|
||||||
|
.matrix-button.is-selected,
|
||||||
|
.quarter-tile.is-flagged,
|
||||||
|
.user-card {
|
||||||
|
background: linear-gradient(135deg, rgba(17, 109, 99, 0.16), rgba(17, 109, 99, 0.08));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card__spacer {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quarter-grid,
|
||||||
|
.pathogen-grid,
|
||||||
|
.user-grid,
|
||||||
|
.check-list,
|
||||||
|
.auth-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quarter-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pathogen-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel__section {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.56);
|
||||||
|
border: 1px solid rgba(37, 49, 58, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-label {
|
||||||
|
margin: 18px 0 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quarter-tile,
|
||||||
|
.user-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quarter-tile strong,
|
||||||
|
.user-card__code {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-bottom: 1px solid rgba(37, 49, 58, 0.08);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill,
|
||||||
|
.info-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
background: rgba(17, 109, 99, 0.08);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--anamnesis {
|
||||||
|
background: rgba(34, 113, 190, 0.12);
|
||||||
|
color: #1a5f9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--antibiogram {
|
||||||
|
background: rgba(151, 88, 202, 0.12);
|
||||||
|
color: #6c3fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--therapy {
|
||||||
|
background: rgba(214, 138, 6, 0.12);
|
||||||
|
color: #8a6500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--completed {
|
||||||
|
background: rgba(45, 106, 79, 0.12);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-button {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-button.is-active {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-button.is-inactive {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link--danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel,
|
||||||
|
.empty-state,
|
||||||
|
.alert {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel,
|
||||||
|
.empty-state {
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert--error {
|
||||||
|
background: rgba(157, 60, 48, 0.12);
|
||||||
|
border-color: rgba(157, 60, 48, 0.18);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert--warning {
|
||||||
|
background: rgba(138, 101, 0, 0.12);
|
||||||
|
border-color: rgba(138, 101, 0, 0.16);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert--success {
|
||||||
|
background: rgba(45, 106, 79, 0.12);
|
||||||
|
border-color: rgba(45, 106, 79, 0.16);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: stretch;
|
||||||
|
width: min(1240px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero__copy {
|
||||||
|
position: relative;
|
||||||
|
padding: 56px;
|
||||||
|
border-radius: 34px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(140deg, rgba(14, 33, 36, 0.92), rgba(13, 91, 83, 0.88)),
|
||||||
|
linear-gradient(120deg, rgba(255, 255, 255, 0.1), transparent);
|
||||||
|
color: #f8f3ed;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero__copy::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: auto -6% -18% auto;
|
||||||
|
width: 260px;
|
||||||
|
height: 260px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero__panel {
|
||||||
|
padding: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: -80px;
|
||||||
|
right: -60px;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(17, 109, 99, 0.16);
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text {
|
||||||
|
max-width: 520px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(248, 243, 237, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions--space-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions--align-end {
|
||||||
|
align-self: end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-grid,
|
||||||
|
.form-grid,
|
||||||
|
.field-grid,
|
||||||
|
.metrics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quarter-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.content-area {
|
||||||
|
padding-inline: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-grid,
|
||||||
|
.auth-grid,
|
||||||
|
.pathogen-grid,
|
||||||
|
.quarter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/src/vite-env.d.ts
vendored
Normal file
7
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2021", "WebWorker"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
12
frontend/tsconfig.node.json
Normal file
12
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"lib": ["ES2021", "DOM"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
frontend/tsconfig.node.tsbuildinfo
Normal file
1
frontend/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
2
frontend/vite.config.d.ts
vendored
Normal file
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
9
frontend/vite.config.js
Normal file
9
frontend/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user