Initial MUH app implementation

This commit is contained in:
2026-03-12 11:43:27 +01:00
commit fb8e3c8ef6
69 changed files with 8387 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
backend/target/
frontend/node_modules/
frontend/dist/
.DS_Store

47
.vscode/launch.json vendored Normal file
View 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
View 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
View 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>

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package de.svencarstensen.muh.domain;
public record AntibiogramEntry(
String antibioticBusinessKey,
String antibioticCode,
String antibioticName,
SensitivityResult result
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,9 @@
package de.svencarstensen.muh.domain;
public enum MedicationCategory {
IN_UDDER,
SYSTEMIC_ANTIBIOTIC,
SYSTEMIC_PAIN,
DRY_SEALER,
DRY_ANTIBIOTIC
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,8 @@
package de.svencarstensen.muh.domain;
public enum PathogenKind {
BACTERIAL,
NO_GROWTH,
CONTAMINATED,
OTHER
}

View File

@@ -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
) {
}

View File

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

View File

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

View File

@@ -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
) {
}

View File

@@ -0,0 +1,6 @@
package de.svencarstensen.muh.domain;
public enum SampleKind {
LACTATION,
DRY_OFF
}

View File

@@ -0,0 +1,8 @@
package de.svencarstensen.muh.domain;
public enum SampleWorkflowStep {
ANAMNESIS,
ANTIBIOGRAM,
THERAPY,
COMPLETED
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.domain;
public enum SamplingMode {
SINGLE_SITE,
FOUR_QUARTER,
UNKNOWN_SITE
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.domain;
public enum SensitivityResult {
SENSITIVE,
INTERMEDIATE,
RESISTANT
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.domain;
public enum UserRole {
APP,
ADMIN,
CUSTOMER
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
) {
}
}

View File

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

View File

@@ -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
) {
}
}

View File

@@ -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
) {
}
}

View File

@@ -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
) {
}
}

View File

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

View File

@@ -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) {
}
}

View File

@@ -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) {
}
}

View File

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

View File

@@ -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
) {
}
}

View 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}

View File

@@ -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
View File

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

1786
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,326 @@
import { useEffect, useMemo, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import type { AdministrationOverview, MedicationCategory, PathogenKind } from "../lib/types";
type DatasetKey = "farmers" | "medications" | "pathogens" | "antibiotics";
type EditableRow = {
id: string;
businessKey: string;
name: string;
active: boolean;
updatedAt: string;
email?: string;
category?: MedicationCategory;
code?: string;
kind?: PathogenKind;
};
type DatasetsState = Record<DatasetKey, EditableRow[]>;
const DATASET_LABELS: Record<DatasetKey, string> = {
farmers: "Landwirte",
medications: "Medikamente",
pathogens: "Erreger",
antibiotics: "Antibiogramm",
};
function normalizeOverview(overview: AdministrationOverview): DatasetsState {
return {
farmers: overview.farmers.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
name: entry.name,
email: entry.email ?? "",
active: entry.active,
updatedAt: entry.updatedAt,
})),
medications: overview.medications.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
name: entry.name,
category: entry.category,
active: entry.active,
updatedAt: entry.updatedAt,
})),
pathogens: overview.pathogens.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
code: entry.code ?? "",
name: entry.name,
kind: entry.kind,
active: entry.active,
updatedAt: entry.updatedAt,
})),
antibiotics: overview.antibiotics.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
code: entry.code ?? "",
name: entry.name,
active: entry.active,
updatedAt: entry.updatedAt,
})),
};
}
function emptyRow(dataset: DatasetKey): EditableRow {
switch (dataset) {
case "farmers":
return { id: "", businessKey: "", name: "", email: "", active: true, updatedAt: new Date().toISOString() };
case "medications":
return {
id: "",
businessKey: "",
name: "",
category: "IN_UDDER",
active: true,
updatedAt: new Date().toISOString(),
};
case "pathogens":
return {
id: "",
businessKey: "",
code: "",
name: "",
kind: "BACTERIAL",
active: true,
updatedAt: new Date().toISOString(),
};
case "antibiotics":
return { id: "", businessKey: "", code: "", name: "", active: true, updatedAt: new Date().toISOString() };
}
}
export default function AdministrationPage() {
const [datasets, setDatasets] = useState<DatasetsState | null>(null);
const [selectedDataset, setSelectedDataset] = useState<DatasetKey>("farmers");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiGet<AdministrationOverview>("/admin");
setDatasets(normalizeOverview(response));
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
void load();
}, []);
const rows = useMemo(() => datasets?.[selectedDataset] ?? [], [datasets, selectedDataset]);
function updateRow(index: number, patch: Partial<EditableRow>) {
setDatasets((current) => {
if (!current) {
return current;
}
const nextRows = current[selectedDataset].map((row, rowIndex) =>
rowIndex === index ? { ...row, ...patch } : row,
);
return {
...current,
[selectedDataset]: nextRows,
};
});
}
function addRow() {
setDatasets((current) => {
if (!current) {
return current;
}
return {
...current,
[selectedDataset]: [...current[selectedDataset], emptyRow(selectedDataset)],
};
});
}
async function handleSave() {
if (!datasets) {
return;
}
setSaving(true);
setMessage(null);
try {
let response: EditableRow[];
switch (selectedDataset) {
case "farmers":
response = await apiPost<EditableRow[]>("/admin/farmers", rows.map((row) => ({
id: row.id || null,
name: row.name,
email: row.email || null,
active: row.active,
})));
break;
case "medications":
response = await apiPost<EditableRow[]>("/admin/medications", rows.map((row) => ({
id: row.id || null,
name: row.name,
category: row.category,
active: row.active,
})));
break;
case "pathogens":
response = await apiPost<EditableRow[]>("/admin/pathogens", rows.map((row) => ({
id: row.id || null,
code: row.code || null,
name: row.name,
kind: row.kind,
active: row.active,
})));
break;
case "antibiotics":
response = await apiPost<EditableRow[]>("/admin/antibiotics", rows.map((row) => ({
id: row.id || null,
code: row.code || null,
name: row.name,
active: row.active,
})));
break;
}
setDatasets((current) => (current ? { ...current, [selectedDataset]: response } : current));
setMessage("Aenderungen gespeichert.");
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Stammdaten direkt pflegen</h3>
<p className="muted-text">
Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte
Satz inaktiv sichtbar.
</p>
</div>
{message ? (
<div className={message.includes("gespeichert") ? "alert alert--success" : "alert alert--error"}>
{message}
</div>
) : null}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Datensatz</p>
<h3>{DATASET_LABELS[selectedDataset]}</h3>
</div>
<div className="choice-row">
{(Object.keys(DATASET_LABELS) as DatasetKey[]).map((dataset) => (
<button
key={dataset}
type="button"
className={`choice-chip ${selectedDataset === dataset ? "is-selected" : ""}`}
onClick={() => setSelectedDataset(dataset)}
>
{DATASET_LABELS[dataset]}
</button>
))}
</div>
</div>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
{selectedDataset === "farmers" ? <th>E-Mail</th> : null}
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
{selectedDataset === "pathogens" ? <th>Typ</th> : null}
<th>Aktiv</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.id || "new"}-${index}`}>
<td>
<input
value={row.name}
onChange={(event) => updateRow(index, { name: event.target.value })}
/>
</td>
{selectedDataset === "farmers" ? (
<td>
<input
value={row.email ?? ""}
onChange={(event) => updateRow(index, { email: event.target.value })}
/>
</td>
) : null}
{selectedDataset === "medications" ? (
<td>
<select
value={row.category}
onChange={(event) =>
updateRow(index, { category: event.target.value as MedicationCategory })
}
>
<option value="IN_UDDER">ins Euter</option>
<option value="SYSTEMIC_ANTIBIOTIC">systemisch Antibiotika</option>
<option value="SYSTEMIC_PAIN">systemisch Schmerzmittel</option>
<option value="DRY_SEALER">Versiegler</option>
<option value="DRY_ANTIBIOTIC">TS Antibiotika</option>
</select>
</td>
) : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? (
<td>
<input
value={row.code ?? ""}
onChange={(event) => updateRow(index, { code: event.target.value })}
/>
</td>
) : null}
{selectedDataset === "pathogens" ? (
<td>
<select
value={row.kind}
onChange={(event) => updateRow(index, { kind: event.target.value as PathogenKind })}
>
<option value="BACTERIAL">bakteriell</option>
<option value="NO_GROWTH">kein Wachstum</option>
<option value="CONTAMINATED">verunreinigt</option>
<option value="OTHER">sonstiges</option>
</select>
</td>
) : null}
<td>
<button
type="button"
className={`eye-button ${row.active ? "is-active" : "is-inactive"}`}
onClick={() => updateRow(index, { active: !row.active })}
>
{row.active ? "sichtbar" : "inaktiv"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="page-actions page-actions--space-between">
<button type="button" className="secondary-button" onClick={addRow}>
Anlegen
</button>
<button type="button" className="accent-button" onClick={() => void handleSave()} disabled={saving}>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiGet, apiPut } from "../lib/api";
import type { ActiveCatalogSummary, QuarterKey, QuarterView, SampleDetail } from "../lib/types";
type QuarterFormState = {
pathogenBusinessKey: string;
customPathogenName: string;
cellCount: string;
};
function quarterStateFromSample(sample: SampleDetail) {
return sample.quarters.reduce<Record<string, QuarterFormState>>((accumulator, quarter) => {
accumulator[quarter.quarterKey] = {
pathogenBusinessKey: quarter.pathogenBusinessKey ?? "",
customPathogenName: quarter.customPathogenName ?? "",
cellCount: quarter.cellCount ? String(quarter.cellCount) : "",
};
return accumulator;
}, {});
}
export default function AnamnesisPage() {
const { sampleId } = useParams();
const navigate = useNavigate();
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
const [sample, setSample] = useState<SampleDetail | null>(null);
const [quarterStates, setQuarterStates] = useState<Record<string, QuarterFormState>>({});
const [activeQuarter, setActiveQuarter] = useState<QuarterKey | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
async function load() {
if (!sampleId) {
return;
}
try {
const [catalogResponse, sampleResponse] = await Promise.all([
apiGet<ActiveCatalogSummary>("/catalogs/summary"),
apiGet<SampleDetail>(`/samples/${sampleId}`),
]);
setCatalogs(catalogResponse);
setSample(sampleResponse);
setQuarterStates(quarterStateFromSample(sampleResponse));
setActiveQuarter(sampleResponse.quarters[0]?.quarterKey ?? null);
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
void load();
}, [sampleId]);
const visibleQuarter = useMemo<QuarterView | null>(() => {
if (!sample) {
return null;
}
return sample.quarters.find((quarter) => quarter.quarterKey === activeQuarter) ?? sample.quarters[0] ?? null;
}, [activeQuarter, sample]);
function updateQuarter(quarterKey: QuarterKey, patch: Partial<QuarterFormState>) {
setQuarterStates((current) => ({
...current,
[quarterKey]: {
...current[quarterKey],
...patch,
},
}));
}
async function handleSave() {
if (!sampleId || !sample) {
return;
}
setSaving(true);
setMessage(null);
try {
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/anamnesis`, {
quarters: sample.quarters.map((quarter) => ({
quarterKey: quarter.quarterKey,
pathogenBusinessKey: quarterStates[quarter.quarterKey]?.pathogenBusinessKey || null,
customPathogenName: quarterStates[quarter.quarterKey]?.customPathogenName || null,
cellCount: quarterStates[quarter.quarterKey]?.cellCount
? Number(quarterStates[quarter.quarterKey]?.cellCount)
: null,
})),
});
navigate(`/samples/${response.id}/${response.routeSegment}`);
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
if (!sample || !catalogs || !visibleQuarter) {
return <div className="empty-state">Anamnese wird geladen ...</div>;
}
const state = quarterStates[visibleQuarter.quarterKey] ?? {
pathogenBusinessKey: "",
customPathogenName: "",
cellCount: "",
};
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Anamnese</p>
<h3>Probe {sample.sampleNumber}</h3>
<p className="muted-text">
Erreger koennen ueber Schnellwahl oder Freitext erfasst werden. Bei 4/4-Proben wird
jedes relevante Viertel separat dokumentiert.
</p>
</div>
{sample.anamnesisEditable ? null : (
<div className="alert alert--warning">
Die Anamnese ist in diesem Bearbeitungsstand nur noch lesbar.
</div>
)}
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
{sample.quarters.length > 1 ? (
<section className="section-card">
<div className="tab-row">
{sample.quarters.map((quarter) => (
<button
key={quarter.quarterKey}
type="button"
className={`tab-chip ${activeQuarter === quarter.quarterKey ? "is-active" : ""}`}
onClick={() => setActiveQuarter(quarter.quarterKey)}
>
{quarter.label}
{quarter.flagged ? " ⚠" : ""}
</button>
))}
</div>
</section>
) : null}
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Entnahmestelle</p>
<h3>{visibleQuarter.label}</h3>
{visibleQuarter.flagged ? (
<div className="info-chip">Auffaelliges Viertel markiert</div>
) : null}
<div className="pathogen-grid">
{catalogs.pathogens.map((pathogen) => (
<button
key={pathogen.businessKey}
type="button"
className={`pathogen-button ${
state.pathogenBusinessKey === pathogen.businessKey ? "is-selected" : ""
}`}
onClick={() =>
updateQuarter(visibleQuarter.quarterKey, {
pathogenBusinessKey: pathogen.businessKey,
customPathogenName: "",
})
}
disabled={!sample.anamnesisEditable}
>
<strong>{pathogen.name}</strong>
<small>{pathogen.code ?? pathogen.kind}</small>
</button>
))}
</div>
<label className="field">
<span>Erreger manuell eingeben</span>
<input
value={state.customPathogenName}
onChange={(event) =>
updateQuarter(visibleQuarter.quarterKey, {
customPathogenName: event.target.value,
pathogenBusinessKey: "",
})
}
disabled={!sample.anamnesisEditable}
/>
</label>
</article>
<article className="section-card">
<p className="eyebrow">Begleitdaten</p>
<label className="field">
<span>Zellzahl {sample.sampleKind === "DRY_OFF" ? "(optional)" : ""}</span>
<input
value={state.cellCount}
onChange={(event) => updateQuarter(visibleQuarter.quarterKey, { cellCount: event.target.value })}
disabled={!sample.anamnesisEditable}
inputMode="numeric"
/>
</label>
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Kein Wachstum oder verunreinigte Proben werden spaeter automatisch vom
Antibiogramm ausgeschlossen.
</p>
</div>
</article>
</section>
<div className="page-actions">
<button
type="button"
className="accent-button"
onClick={() => void handleSave()}
disabled={saving || !sample.anamnesisEditable}
>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiGet, apiPut } from "../lib/api";
import type {
ActiveCatalogSummary,
QuarterKey,
SampleDetail,
SensitivityResult,
} from "../lib/types";
type GroupState = Record<string, SensitivityResult | undefined>;
function quarterIdentity(sample: SampleDetail, quarterKey: QuarterKey) {
const quarter = sample.quarters.find((entry) => entry.quarterKey === quarterKey);
if (!quarter) {
return quarterKey;
}
return quarter.pathogenBusinessKey || quarter.customPathogenName || quarter.pathogenName || quarterKey;
}
export default function AntibiogramPage() {
const { sampleId } = useParams();
const navigate = useNavigate();
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
const [sample, setSample] = useState<SampleDetail | null>(null);
const [groupState, setGroupState] = useState<Record<string, GroupState>>({});
const [message, setMessage] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
async function load() {
if (!sampleId) {
return;
}
try {
const [catalogResponse, sampleResponse] = await Promise.all([
apiGet<ActiveCatalogSummary>("/catalogs/summary"),
apiGet<SampleDetail>(`/samples/${sampleId}`),
]);
setCatalogs(catalogResponse);
setSample(sampleResponse);
const nextState: Record<string, GroupState> = {};
sampleResponse.antibiogramTargets.forEach((referenceQuarter) => {
const existingGroup =
sampleResponse.antibiograms.find((entry) => entry.quarterKey === referenceQuarter) ??
sampleResponse.antibiograms.find((entry) => entry.inheritedFromQuarter === referenceQuarter);
nextState[referenceQuarter] = {};
existingGroup?.entries.forEach((entry) => {
nextState[referenceQuarter][entry.antibioticBusinessKey] = entry.result;
});
});
setGroupState(nextState);
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
void load();
}, [sampleId]);
const groups = useMemo(() => {
if (!sample) {
return [];
}
return sample.antibiogramTargets.map((referenceQuarter) => {
const identity = quarterIdentity(sample, referenceQuarter);
const reference = sample.quarters.find((quarter) => quarter.quarterKey === referenceQuarter);
const inherited = sample.quarters.filter(
(quarter) =>
quarter.quarterKey !== referenceQuarter &&
quarterIdentity(sample, quarter.quarterKey) === identity &&
quarter.requiresAntibiogram,
);
return { referenceQuarter, reference, inherited };
});
}, [sample]);
function updateResult(
referenceQuarter: QuarterKey,
antibioticBusinessKey: string,
result: SensitivityResult,
) {
setGroupState((current) => ({
...current,
[referenceQuarter]: {
...current[referenceQuarter],
[antibioticBusinessKey]:
current[referenceQuarter]?.[antibioticBusinessKey] === result ? undefined : result,
},
}));
}
async function handleSave() {
if (!sampleId || !sample) {
return;
}
setSaving(true);
setMessage(null);
try {
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/antibiogram`, {
groups: groups.map((group) => ({
referenceQuarter: group.referenceQuarter,
entries: Object.entries(groupState[group.referenceQuarter] ?? {})
.filter((entry): entry is [string, SensitivityResult] => Boolean(entry[1]))
.map(([antibioticBusinessKey, result]) => ({
antibioticBusinessKey,
result,
})),
})),
});
navigate(`/samples/${response.id}/${response.routeSegment}`);
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
if (!sample || !catalogs) {
return <div className="empty-state">Antibiogramm wird geladen ...</div>;
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Antibiogramm</p>
<h3>Probe {sample.sampleNumber}</h3>
<p className="muted-text">
Nur Viertel mit bakteriellem Wachstum werden angezeigt. Identische Erreger werden
automatisch zusammengefasst.
</p>
</div>
{sample.antibiogramEditable ? null : (
<div className="alert alert--warning">
Das Antibiogramm ist in diesem Bearbeitungsstand nur noch lesbar.
</div>
)}
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
{!groups.length ? (
<div className="empty-state">Fuer diese Probe ist kein Antibiogramm erforderlich.</div>
) : (
groups.map((group) => (
<section key={group.referenceQuarter} className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">{group.reference?.label}</p>
<h3>{group.reference?.customPathogenName || group.reference?.pathogenName || "Erreger"}</h3>
</div>
{group.inherited.length ? (
<div className="info-chip">
Gilt ebenfalls fuer {group.inherited.map((entry) => entry.label).join(", ")}
</div>
) : null}
</div>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Antibiotikum</th>
<th>S</th>
<th>I</th>
<th>R</th>
</tr>
</thead>
<tbody>
{catalogs.antibiotics.map((antibiotic) => (
<tr key={antibiotic.businessKey}>
<td>
<strong>{antibiotic.name}</strong>
<small className="table-subtext">{antibiotic.code ?? "ANT"}</small>
</td>
{(["SENSITIVE", "INTERMEDIATE", "RESISTANT"] as SensitivityResult[]).map((result) => (
<td key={result}>
<button
type="button"
className={`matrix-button ${
groupState[group.referenceQuarter]?.[antibiotic.businessKey] === result
? "is-selected"
: ""
}`}
onClick={() => updateResult(group.referenceQuarter, antibiotic.businessKey, result)}
disabled={!sample.antibiogramEditable}
>
{result === "SENSITIVE" ? "S" : result === "INTERMEDIATE" ? "I" : "R"}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
))
)}
<div className="page-actions">
<button
type="button"
className="accent-button"
onClick={() => void handleSave()}
disabled={saving || !sample.antibiogramEditable}
>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { apiGet } from "../lib/api";
import type { DashboardOverview, LookupResult } from "../lib/types";
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function routeForSample(sampleId: string, routeSegment: string) {
return `/samples/${sampleId}/${routeSegment}`;
}
const STEP_LABELS: Record<string, string> = {
ANAMNESIS: "Anamnese",
ANTIBIOGRAM: "Antibiogramm",
THERAPY: "Therapie",
COMPLETED: "Abgeschlossen",
};
export default function HomePage() {
const navigate = useNavigate();
const [dashboard, setDashboard] = useState<DashboardOverview | null>(null);
const [sampleNumber, setSampleNumber] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadDashboard() {
try {
const response = await apiGet<DashboardOverview>("/dashboard");
setDashboard(response);
} finally {
setLoading(false);
}
}
void loadDashboard();
}, []);
async function handleLookup(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!sampleNumber.trim()) {
setMessage("Bitte eine Probennummer eingeben.");
return;
}
try {
const response = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber.trim()}`);
if (!response.found || !response.sampleId || !response.routeSegment) {
setMessage(response.message);
return;
}
setMessage(null);
navigate(routeForSample(response.sampleId, response.routeSegment));
} catch (lookupError) {
setMessage((lookupError as Error).message);
}
}
return (
<div className="page-stack">
<section className="hero-card">
<div>
<p className="eyebrow">Startseite</p>
<h3>Bearbeitungsstand sofort finden</h3>
<p className="muted-text">
Eine bekannte Probennummer oeffnet direkt den passenden Arbeitsschritt.
</p>
</div>
<form className="hero-card__form" onSubmit={handleLookup}>
<label className="field">
<span>Nummer</span>
<input
value={sampleNumber}
onChange={(event) => setSampleNumber(event.target.value)}
placeholder="z. B. 100203"
inputMode="numeric"
/>
</label>
<button type="submit" className="accent-button">
Probe oeffnen
</button>
<button type="button" className="secondary-button" onClick={() => navigate("/samples/new")}>
Neuanlage einer Probe
</button>
</form>
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="metrics-grid">
<article className="metric-card">
<span className="metric-card__label">Naechste Nummer</span>
<strong>{dashboard?.nextSampleNumber ?? "..."}</strong>
</article>
<article className="metric-card">
<span className="metric-card__label">Offene Proben</span>
<strong>{dashboard?.openSamples ?? "..."}</strong>
</article>
<article className="metric-card">
<span className="metric-card__label">Heute abgeschlossen</span>
<strong>{dashboard?.completedToday ?? "..."}</strong>
</article>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Arbeitsvorrat</p>
<h3>Zuletzt bearbeitete Proben</h3>
</div>
</div>
{loading ? (
<div className="empty-state">Dashboard wird geladen ...</div>
) : dashboard?.recentSamples.length ? (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Probe</th>
<th>Landwirt</th>
<th>Kuh</th>
<th>Typ</th>
<th>Status</th>
<th>Aktualisiert</th>
<th />
</tr>
</thead>
<tbody>
{dashboard.recentSamples.map((sample) => (
<tr key={sample.id}>
<td>{sample.sampleNumber}</td>
<td>{sample.farmerName}</td>
<td>{sample.cowLabel}</td>
<td>{sample.sampleKind === "DRY_OFF" ? "Trockensteller" : "Laktation"}</td>
<td>
<span className={`status-pill status-pill--${sample.currentStep.toLowerCase()}`}>
{STEP_LABELS[sample.currentStep]}
</span>
</td>
<td>{formatDate(sample.updatedAt)}</td>
<td>
<button
type="button"
className="table-link"
onClick={() =>
navigate(
routeForSample(
sample.id,
sample.currentStep === "ANTIBIOGRAM"
? "antibiogram"
: sample.currentStep === "THERAPY" || sample.currentStep === "COMPLETED"
? "therapy"
: "anamnesis",
),
)
}
>
Oeffnen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty-state">Noch keine Proben vorhanden.</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,255 @@
import { FormEvent, useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
type FeedbackState =
| { type: "error"; text: string }
| { type: "success"; text: string }
| null;
export default function LoginPage() {
const [users, setUsers] = useState<UserOption[]>([]);
const [manualCode, setManualCode] = useState("");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [registration, setRegistration] = useState({
companyName: "",
address: "",
email: "",
password: "",
});
const [loading, setLoading] = useState(true);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setUser } = useSession();
async function loadUsers() {
setLoading(true);
setFeedback(null);
try {
const response = await apiGet<UserOption[]>("/session/users");
setUsers(response);
} catch (loadError) {
setFeedback({ type: "error", text: (loadError as Error).message });
setUsers([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadUsers();
}, []);
async function handleCodeLogin(code: string) {
if (!code.trim()) {
setFeedback({ type: "error", text: "Bitte ein Benutzerkuerzel eingeben oder auswaehlen." });
return;
}
try {
const response = await apiPost<UserOption>("/session/login", { code });
setUser(response);
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
}
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
const response = await apiPost<UserOption>("/session/password-login", {
identifier,
password,
});
setUser(response);
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
}
async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
const response = await apiPost<UserOption>("/session/register", registration);
setFeedback({
type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`,
});
setUser(response);
} catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message });
}
}
return (
<div className="login-page">
<section className="login-hero">
<div className="login-hero__copy">
<p className="eyebrow">MUH-App</p>
<h1>Moderne Steuerung fuer Milchproben und Therapien.</h1>
<p className="hero-text">
Fokus auf klare Arbeitsablaeufe, schnelle Probenbearbeitung und ein Portal
fuer Verwaltung, Berichtsdruck und Versandstatus.
</p>
</div>
<div className="login-hero__panel">
<div className="panel-glow" />
<p className="eyebrow">Zugang</p>
<h2>Anmelden oder registrieren</h2>
<p className="muted-text">
Weiterhin moeglich: Direktanmeldung per Benutzerkuerzel. Neu: Login mit
E-Mail/Benutzername und Passwort.
</p>
{feedback ? (
<div className={`alert ${feedback.type === "success" ? "alert--success" : "alert--error"}`}>
{feedback.text}
</div>
) : null}
<div className="login-panel__section">
<div className="section-card__header">
<div>
<p className="eyebrow">Schnelllogin</p>
<h3>Benutzerkuerzel</h3>
</div>
<button type="button" className="secondary-button" onClick={() => void loadUsers()}>
Neu laden
</button>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen ...</div>
) : users.length ? (
<div className="user-grid">
{users.map((user) => (
<button
key={user.id}
type="button"
className="user-card"
onClick={() => void handleCodeLogin(user.code)}
>
<span className="user-card__code">{user.code}</span>
<strong>{user.displayName}</strong>
<small>
{user.role === "ADMIN"
? "Admin"
: user.role === "CUSTOMER"
? "Kunde"
: "App"}
</small>
</button>
))}
</div>
) : (
<div className="page-stack">
<div className="empty-state">
Es wurden keine aktiven Benutzer geladen. Das Kuersel kann trotzdem direkt
eingegeben werden.
</div>
<label className="field">
<span>Benutzerkuerzel</span>
<input
value={manualCode}
onChange={(event) => setManualCode(event.target.value.toUpperCase())}
placeholder="z. B. SV"
/>
</label>
<div className="page-actions">
<button
type="button"
className="accent-button"
onClick={() => void handleCodeLogin(manualCode)}
>
Mit Kuerzel anmelden
</button>
</div>
</div>
)}
</div>
<div className="divider-label">oder mit Passwort</div>
<div className="auth-grid">
<form className="login-panel__section" onSubmit={handlePasswordLogin}>
<p className="eyebrow">Login</p>
<h3>E-Mail oder Benutzername</h3>
<label className="field">
<span>E-Mail / Benutzername</span>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder="z. B. admin oder name@hof.de"
/>
</label>
<label className="field">
<span>Passwort</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Mit Passwort anmelden
</button>
</div>
</form>
<form className="login-panel__section" onSubmit={handleRegister}>
<p className="eyebrow">Kundenregistrierung</p>
<h3>Neues Kundenkonto anlegen</h3>
<label className="field">
<span>Firmenname</span>
<input
value={registration.companyName}
onChange={(event) =>
setRegistration((current) => ({ ...current, companyName: event.target.value }))
}
placeholder="z. B. Muster Agrar GmbH"
/>
</label>
<label className="field">
<span>Adresse</span>
<textarea
value={registration.address}
onChange={(event) =>
setRegistration((current) => ({ ...current, address: event.target.value }))
}
placeholder="Strasse, Hausnummer, PLZ Ort"
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={registration.email}
onChange={(event) =>
setRegistration((current) => ({ ...current, email: event.target.value }))
}
/>
</label>
<label className="field">
<span>Passwort</span>
<input
type="password"
value={registration.password}
onChange={(event) =>
setRegistration((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Registrieren
</button>
</div>
</form>
</div>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,429 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api";
import type { PortalSnapshot, UserRole } from "../lib/types";
function formatDate(value: string | null) {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export default function PortalPage() {
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null);
const [selectedFarmer, setSelectedFarmer] = useState("");
const [farmerQuery, setFarmerQuery] = useState("");
const [cowQuery, setCowQuery] = useState("");
const [sampleNumberQuery, setSampleNumberQuery] = useState("");
const [dateQuery, setDateQuery] = useState("");
const [selectedReports, setSelectedReports] = useState<string[]>([]);
const [message, setMessage] = useState<string | null>(null);
const [userForm, setUserForm] = useState({
code: "",
displayName: "",
email: "",
portalLogin: "",
password: "",
role: "APP" as UserRole,
});
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
async function loadSnapshot() {
const params = new URLSearchParams();
if (selectedFarmer) {
params.set("farmerBusinessKey", selectedFarmer);
}
if (farmerQuery) {
params.set("farmerQuery", farmerQuery);
}
if (cowQuery) {
params.set("cowQuery", cowQuery);
}
if (sampleNumberQuery) {
params.set("sampleNumber", sampleNumberQuery);
}
if (dateQuery) {
params.set("date", dateQuery);
}
const response = await apiGet<PortalSnapshot>(`/portal/snapshot?${params.toString()}`);
setSnapshot(response);
setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId));
}
useEffect(() => {
void loadSnapshot();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reportCount = useMemo(() => selectedReports.length, [selectedReports]);
function toggleReport(sampleId: string) {
setSelectedReports((current) =>
current.includes(sampleId)
? current.filter((entry) => entry !== sampleId)
: [...current, sampleId],
);
}
async function handleSearch(event?: FormEvent) {
event?.preventDefault();
try {
setMessage(null);
await loadSnapshot();
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
async function handleDispatchReports() {
try {
const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", {
sampleIds: selectedReports,
});
setMessage(
response.mailDeliveryActive
? "Berichte wurden versendet."
: "Berichte wurden als versendet markiert. Fuer echten Mailversand fehlt noch SMTP-Konfiguration.",
);
await loadSnapshot();
} catch (dispatchError) {
setMessage((dispatchError as Error).message);
}
}
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
await apiPost("/portal/users", {
...userForm,
active: true,
});
setUserForm({
code: "",
displayName: "",
email: "",
portalLogin: "",
password: "",
role: "APP",
});
setMessage("Benutzer gespeichert.");
await loadSnapshot();
} catch (userError) {
setMessage((userError as Error).message);
}
}
async function handleDeleteUser(userId: string) {
try {
await apiDelete(`/portal/users/${userId}`);
setMessage("Benutzer geloescht.");
await loadSnapshot();
} catch (deleteError) {
setMessage((deleteError as Error).message);
}
}
async function handlePasswordChange(userId: string) {
try {
await apiPost(`/portal/users/${userId}/password`, {
password: passwordDrafts[userId],
});
setPasswordDrafts((current) => ({ ...current, [userId]: "" }));
setMessage("Passwort aktualisiert.");
} catch (passwordError) {
setMessage((passwordError as Error).message);
}
}
async function handleToggleBlocked(sampleId: string, blocked: boolean) {
try {
await apiPatch(`/portal/reports/${sampleId}/block`, { blocked });
await loadSnapshot();
} catch (blockError) {
setMessage((blockError as Error).message);
}
}
if (!snapshot) {
return <div className="empty-state">Portal wird geladen ...</div>;
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">MUH-Portal</p>
<h3>Benutzer, Berichtversand und Schnellsuche</h3>
<p className="muted-text">
Das Portal kombiniert Verwaltungsfunktionen mit dem Versandstatus aller
abgeschlossenen Proben.
</p>
</div>
{message ? (
<div className={message.includes("gespeichert") || message.includes("versendet") || message.includes("aktualisiert") || message.includes("geloescht") ? "alert alert--success" : "alert alert--error"}>
{message}
</div>
) : null}
</section>
<section className="portal-grid">
<article className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Bericht-Versand</p>
<h3>Versandbereite Proben</h3>
</div>
<div className="info-chip">{reportCount} markiert</div>
</div>
<div className="check-list">
{snapshot.reportCandidates.map((candidate) => (
<label key={candidate.sampleId} className="check-list__item">
<input
type="checkbox"
checked={selectedReports.includes(candidate.sampleId)}
onChange={() => toggleReport(candidate.sampleId)}
/>
<span>
Probe {candidate.sampleNumber} | {candidate.farmerName} | {candidate.farmerEmail}
</span>
</label>
))}
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void handleDispatchReports()}>
Markierte Mails versenden
</button>
</div>
</article>
<article className="section-card">
<p className="eyebrow">Benutzerverwaltung</p>
<form className="field-grid" onSubmit={handleCreateUser}>
<label className="field">
<span>Kuerzel</span>
<input
value={userForm.code}
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
/>
</label>
<label className="field">
<span>Name</span>
<input
value={userForm.displayName}
onChange={(event) =>
setUserForm((current) => ({ ...current, displayName: event.target.value }))
}
/>
</label>
<label className="field">
<span>Login</span>
<input
value={userForm.portalLogin}
onChange={(event) =>
setUserForm((current) => ({ ...current, portalLogin: event.target.value }))
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={userForm.email}
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<label className="field">
<span>Passwort</span>
<input
value={userForm.password}
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
type="password"
/>
</label>
<label className="field">
<span>Rolle</span>
<select
value={userForm.role}
onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))}
>
<option value="APP">APP</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div>
</form>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Kuerzel</th>
<th>Name</th>
<th>E-Mail</th>
<th>Login</th>
<th>Rolle</th>
<th>Passwort</th>
<th />
</tr>
</thead>
<tbody>
{snapshot.users.map((user) => (
<tr key={user.id}>
<td>{user.code}</td>
<td>{user.displayName}</td>
<td>{user.email ?? "-"}</td>
<td>{user.portalLogin ?? "-"}</td>
<td>{user.role}</td>
<td>
<input
type="password"
value={passwordDrafts[user.id] ?? ""}
onChange={(event) =>
setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
}
placeholder="Neues Passwort"
/>
</td>
<td className="table-actions">
<button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
Speichern
</button>
<button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
</section>
<section className="section-card">
<form className="field-grid" onSubmit={handleSearch}>
<label className="field">
<span>Landwirt suchen</span>
<input value={farmerQuery} onChange={(event) => setFarmerQuery(event.target.value)} />
</label>
<label className="field">
<span>Gefundener Landwirt</span>
<select value={selectedFarmer} onChange={(event) => setSelectedFarmer(event.target.value)}>
<option value="">alle / noch keiner</option>
{snapshot.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name}
</option>
))}
</select>
</label>
<label className="field">
<span>Kuh</span>
<input value={cowQuery} onChange={(event) => setCowQuery(event.target.value)} />
</label>
<label className="field">
<span>Probe-Nr.</span>
<input value={sampleNumberQuery} onChange={(event) => setSampleNumberQuery(event.target.value)} />
</label>
<label className="field">
<span>Datum</span>
<input type="date" value={dateQuery} onChange={(event) => setDateQuery(event.target.value)} />
</label>
<div className="page-actions page-actions--align-end">
<button type="submit" className="accent-button">
Suche starten
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
setSelectedFarmer("");
setFarmerQuery("");
setCowQuery("");
setSampleNumberQuery("");
setDateQuery("");
void handleSearch();
}}
>
Zuruecksetzen
</button>
</div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Suchergebnis</p>
<h3>Gefundene Milchproben</h3>
</div>
</div>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Probe</th>
<th>Anlage</th>
<th>Landwirt</th>
<th>Kuh</th>
<th>Typ</th>
<th>Interne Bemerkung</th>
<th>PDF</th>
<th>Versand</th>
</tr>
</thead>
<tbody>
{snapshot.samples.map((sample) => (
<tr key={sample.sampleId}>
<td>{sample.sampleNumber}</td>
<td>{formatDate(sample.createdAt)}</td>
<td>{sample.farmerName}</td>
<td>{sample.cowNumber}{sample.cowName ? ` / ${sample.cowName}` : ""}</td>
<td>{sample.sampleKindLabel === "DRY_OFF" ? "Trockensteller" : "Milchprobe"}</td>
<td>{sample.internalNote ?? "-"}</td>
<td>
{sample.completed ? (
<a className="table-link" href={pdfUrl(sample.sampleId)} target="_blank" rel="noreferrer">
PDF
</a>
) : (
<span className="muted-text">-</span>
)}
</td>
<td>
<div className="table-actions">
<span className={`status-pill ${sample.reportSent ? "status-pill--completed" : "status-pill--therapy"}`}>
{sample.reportSent ? "versendet" : "offen"}
</span>
{sample.completed ? (
<button
type="button"
className="table-link"
onClick={() => void handleToggleBlocked(sample.sampleId, !sample.reportBlocked)}
>
{sample.reportBlocked ? "freigeben" : "blockieren"}
</button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiGet, apiPost, apiPut } from "../lib/api";
import { useSession } from "../lib/session";
import type {
ActiveCatalogSummary,
DashboardOverview,
QuarterKey,
SampleDetail,
SampleKind,
SamplingMode,
} from "../lib/types";
const QUARTERS: { key: QuarterKey; label: string }[] = [
{ key: "LEFT_FRONT", label: "Vorne links" },
{ key: "RIGHT_FRONT", label: "Vorne rechts" },
{ key: "LEFT_REAR", label: "Hinten links" },
{ key: "RIGHT_REAR", label: "Hinten rechts" },
];
export default function SampleRegistrationPage() {
const { sampleId } = useParams();
const navigate = useNavigate();
const { user } = useSession();
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
const [sampleNumber, setSampleNumber] = useState<number | null>(null);
const [editable, setEditable] = useState(true);
const [farmerBusinessKey, setFarmerBusinessKey] = useState("");
const [cowNumber, setCowNumber] = useState("");
const [cowName, setCowName] = useState("");
const [sampleKind, setSampleKind] = useState<SampleKind>("LACTATION");
const [samplingMode, setSamplingMode] = useState<SamplingMode>("SINGLE_SITE");
const [flaggedQuarters, setFlaggedQuarters] = useState<QuarterKey[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const catalogResponse = await apiGet<ActiveCatalogSummary>("/catalogs/summary");
setCatalogs(catalogResponse);
if (sampleId) {
const sample = await apiGet<SampleDetail>(`/samples/${sampleId}`);
setSampleNumber(sample.sampleNumber);
setEditable(sample.registrationEditable);
setFarmerBusinessKey(sample.farmerBusinessKey);
setCowNumber(sample.cowNumber);
setCowName(sample.cowName ?? "");
setSampleKind(sample.sampleKind);
setSamplingMode(sample.samplingMode);
setFlaggedQuarters(
sample.quarters.filter((quarter) => quarter.flagged).map((quarter) => quarter.quarterKey),
);
} else {
const dashboard = await apiGet<DashboardOverview>("/dashboard");
setSampleNumber(dashboard.nextSampleNumber);
setFarmerBusinessKey(catalogResponse.farmers[0]?.businessKey ?? "");
}
} catch (loadError) {
setMessage((loadError as Error).message);
} finally {
setLoading(false);
}
}
void load();
}, [sampleId]);
function toggleFlaggedQuarter(quarterKey: QuarterKey) {
setFlaggedQuarters((current) =>
current.includes(quarterKey)
? current.filter((entry) => entry !== quarterKey)
: [...current, quarterKey],
);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!user) {
return;
}
if (!farmerBusinessKey || !cowNumber.trim()) {
setMessage("Landwirt und Kuh-Nummer sind erforderlich.");
return;
}
setSaving(true);
setMessage(null);
const payload = {
farmerBusinessKey,
cowNumber,
cowName,
sampleKind,
samplingMode,
flaggedQuarters,
userCode: user.code,
userDisplayName: user.displayName,
};
try {
const response = sampleId
? await apiPut<SampleDetail>(`/samples/${sampleId}/registration`, payload)
: await apiPost<SampleDetail>("/samples", payload);
navigate(`/samples/${response.id}/${response.routeSegment}`);
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
if (loading) {
return <div className="empty-state">Probe wird vorbereitet ...</div>;
}
return (
<form className="page-stack" onSubmit={handleSubmit}>
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Neuanlage</p>
<h3>Probe {sampleNumber ?? "..."}</h3>
<p className="muted-text">
Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich ueber den
Schalter TS markieren.
</p>
</div>
{!editable ? (
<div className="alert alert--warning">
Diese Probe ist bereits weiter im Ablauf. Stammdaten sind nicht mehr editierbar.
</div>
) : null}
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Stammdaten</p>
<div className="field-grid">
<label className="field">
<span>Landwirt</span>
<select
value={farmerBusinessKey}
onChange={(event) => setFarmerBusinessKey(event.target.value)}
disabled={!editable}
>
{catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name}
</option>
))}
</select>
</label>
<label className="field">
<span>Kuh-Nummer</span>
<input
value={cowNumber}
onChange={(event) => setCowNumber(event.target.value)}
disabled={!editable}
/>
</label>
<label className="field">
<span>Kuh-Name</span>
<input
value={cowName}
onChange={(event) => setCowName(event.target.value)}
disabled={!editable}
/>
</label>
</div>
</article>
<article className="section-card">
<p className="eyebrow">Probentyp</p>
<div className="choice-row">
<button
type="button"
className={`choice-chip ${sampleKind === "LACTATION" ? "is-selected" : ""}`}
onClick={() => setSampleKind("LACTATION")}
disabled={!editable}
>
Laktationsprobe
</button>
<button
type="button"
className={`choice-chip ${sampleKind === "DRY_OFF" ? "is-selected" : ""}`}
onClick={() => setSampleKind("DRY_OFF")}
disabled={!editable}
>
TS
</button>
</div>
<p className="eyebrow section-card__spacer">Entnahmestelle</p>
<div className="choice-row">
<button
type="button"
className={`choice-chip ${samplingMode === "SINGLE_SITE" ? "is-selected" : ""}`}
onClick={() => setSamplingMode("SINGLE_SITE")}
disabled={!editable}
>
Einzelprobe
</button>
<button
type="button"
className={`choice-chip ${samplingMode === "FOUR_QUARTER" ? "is-selected" : ""}`}
onClick={() => setSamplingMode("FOUR_QUARTER")}
disabled={!editable}
>
4/4 Probe
</button>
<button
type="button"
className={`choice-chip ${samplingMode === "UNKNOWN_SITE" ? "is-selected" : ""}`}
onClick={() => setSamplingMode("UNKNOWN_SITE")}
disabled={!editable}
>
Unbek.
</button>
</div>
</article>
</section>
{samplingMode === "FOUR_QUARTER" ? (
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Auffaellige Viertel</p>
<h3>Viertel markieren</h3>
</div>
</div>
<div className="quarter-grid">
{QUARTERS.map((quarter) => (
<button
key={quarter.key}
type="button"
className={`quarter-tile ${flaggedQuarters.includes(quarter.key) ? "is-flagged" : ""}`}
onClick={() => toggleFlaggedQuarter(quarter.key)}
disabled={!editable}
>
<span>{quarter.label}</span>
<strong>{flaggedQuarters.includes(quarter.key) ? "⚠" : "OK"}</strong>
</button>
))}
</div>
</section>
) : null}
<div className="page-actions">
<button type="submit" className="accent-button" disabled={saving || !editable}>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,284 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { apiGet, apiPut } from "../lib/api";
import type {
ActiveCatalogSummary,
MedicationCategory,
SampleDetail,
} from "../lib/types";
function medicationOptions(catalogs: ActiveCatalogSummary, category: MedicationCategory) {
return catalogs.medications.filter((medication) => medication.category === category);
}
export default function TherapyPage() {
const { sampleId } = useParams();
const [catalogs, setCatalogs] = useState<ActiveCatalogSummary | null>(null);
const [sample, setSample] = useState<SampleDetail | null>(null);
const [continueStarted, setContinueStarted] = useState(false);
const [switchTherapy, setSwitchTherapy] = useState(false);
const [inUdderMedicationKeys, setInUdderMedicationKeys] = useState<string[]>([]);
const [inUdderOther, setInUdderOther] = useState("");
const [systemicMedicationKeys, setSystemicMedicationKeys] = useState<string[]>([]);
const [systemicOther, setSystemicOther] = useState("");
const [drySealerKeys, setDrySealerKeys] = useState<string[]>([]);
const [dryAntibioticKeys, setDryAntibioticKeys] = useState<string[]>([]);
const [farmerNote, setFarmerNote] = useState("");
const [internalNote, setInternalNote] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
async function load() {
if (!sampleId) {
return;
}
try {
const [catalogResponse, sampleResponse] = await Promise.all([
apiGet<ActiveCatalogSummary>("/catalogs/summary"),
apiGet<SampleDetail>(`/samples/${sampleId}`),
]);
setCatalogs(catalogResponse);
setSample(sampleResponse);
setContinueStarted(sampleResponse.therapy?.continueStarted ?? false);
setSwitchTherapy(sampleResponse.therapy?.switchTherapy ?? false);
setInUdderMedicationKeys(sampleResponse.therapy?.inUdderMedicationKeys ?? []);
setInUdderOther(sampleResponse.therapy?.inUdderOther ?? "");
setSystemicMedicationKeys(sampleResponse.therapy?.systemicMedicationKeys ?? []);
setSystemicOther(sampleResponse.therapy?.systemicOther ?? "");
setDrySealerKeys(sampleResponse.therapy?.drySealerKeys ?? []);
setDryAntibioticKeys(sampleResponse.therapy?.dryAntibioticKeys ?? []);
setFarmerNote(sampleResponse.therapy?.farmerNote ?? "");
setInternalNote(sampleResponse.therapy?.internalNote ?? "");
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
void load();
}, [sampleId]);
const therapyLocked = useMemo(() => sample?.completed ?? false, [sample]);
function toggleSelection(list: string[], value: string, setter: (next: string[]) => void) {
setter(list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]);
}
async function handleSave() {
if (!sampleId) {
return;
}
setSaving(true);
setMessage(null);
try {
const response = await apiPut<SampleDetail>(`/samples/${sampleId}/therapy`, {
continueStarted,
switchTherapy,
inUdderMedicationKeys,
inUdderOther,
systemicMedicationKeys,
systemicOther,
drySealerKeys,
dryAntibioticKeys,
farmerNote,
internalNote,
});
setSample(response);
setMessage(response.completed ? "Probe gespeichert und abgeschlossen." : "Aenderung gespeichert.");
} catch (saveError) {
setMessage((saveError as Error).message);
} finally {
setSaving(false);
}
}
if (!sample || !catalogs) {
return <div className="empty-state">Therapieempfehlung wird geladen ...</div>;
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Therapieempfehlung</p>
<h3>Probe {sample.sampleNumber}</h3>
<p className="muted-text">
Laktations- und Trockenstellerproben verwenden unterschiedliche Medikationsgruppen.
Bei abgeschlossenen Proben bleibt nur die interne Bemerkung editierbar.
</p>
</div>
{sample.completed ? (
<div className="alert alert--warning">
Probe abgeschlossen. Nur das Feld Interne Bemerkung kann noch angepasst werden.
</div>
) : null}
{message ? <div className={message.includes("gespeichert") ? "alert alert--success" : "alert alert--error"}>{message}</div> : null}
</section>
{sample.sampleKind === "LACTATION" ? (
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Empfehlung / Therapie</p>
<div className="choice-row">
<button
type="button"
className={`choice-chip ${continueStarted ? "is-selected" : ""}`}
onClick={() => {
setContinueStarted((current) => !current);
if (!continueStarted) {
setSwitchTherapy(false);
}
}}
disabled={therapyLocked}
>
weiter wie begonnen
</button>
<button
type="button"
className={`choice-chip ${switchTherapy ? "is-selected" : ""}`}
onClick={() => {
setSwitchTherapy((current) => !current);
if (!switchTherapy) {
setContinueStarted(false);
}
}}
disabled={therapyLocked}
>
umstellen
</button>
</div>
<p className="eyebrow section-card__spacer">ins Euter</p>
<div className="choice-row choice-row--wrap">
{medicationOptions(catalogs, "IN_UDDER").map((medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${inUdderMedicationKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() =>
toggleSelection(inUdderMedicationKeys, medication.businessKey, setInUdderMedicationKeys)
}
disabled={therapyLocked}
>
{medication.name}
</button>
))}
</div>
<label className="field">
<span>Sonstiges</span>
<textarea
value={inUdderOther}
onChange={(event) => setInUdderOther(event.target.value)}
disabled={therapyLocked}
/>
</label>
</article>
<article className="section-card">
<p className="eyebrow">Systemisch</p>
<div className="choice-row choice-row--wrap">
{[...medicationOptions(catalogs, "SYSTEMIC_PAIN"), ...medicationOptions(catalogs, "SYSTEMIC_ANTIBIOTIC")].map(
(medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${systemicMedicationKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() =>
toggleSelection(
systemicMedicationKeys,
medication.businessKey,
setSystemicMedicationKeys,
)
}
disabled={therapyLocked}
>
{medication.name}
</button>
),
)}
</div>
<label className="field">
<span>Sonstiges</span>
<textarea
value={systemicOther}
onChange={(event) => setSystemicOther(event.target.value)}
disabled={therapyLocked}
/>
</label>
</article>
</section>
) : (
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Trockensteller</p>
<h3>Versiegler</h3>
<div className="choice-row choice-row--wrap">
{medicationOptions(catalogs, "DRY_SEALER").map((medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${drySealerKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() => toggleSelection(drySealerKeys, medication.businessKey, setDrySealerKeys)}
disabled={therapyLocked}
>
{medication.name}
</button>
))}
</div>
</article>
<article className="section-card">
<p className="eyebrow">Trockensteller</p>
<h3>Antibiotika</h3>
<div className="choice-row choice-row--wrap">
{medicationOptions(catalogs, "DRY_ANTIBIOTIC").map((medication) => (
<button
key={medication.businessKey}
type="button"
className={`choice-chip ${dryAntibioticKeys.includes(medication.businessKey) ? "is-selected" : ""}`}
onClick={() =>
toggleSelection(dryAntibioticKeys, medication.businessKey, setDryAntibioticKeys)
}
disabled={therapyLocked}
>
{medication.name}
</button>
))}
</div>
</article>
</section>
)}
<section className="form-grid">
<article className="section-card">
<label className="field">
<span>Anmerkung fuer Landwirt</span>
<textarea
value={farmerNote}
onChange={(event) => setFarmerNote(event.target.value)}
disabled={therapyLocked}
/>
</label>
</article>
<article className="section-card">
<label className="field">
<span>Interne Bemerkung</span>
<textarea value={internalNote} onChange={(event) => setInternalNote(event.target.value)} />
</label>
</article>
</section>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void handleSave()} disabled={saving}>
{saving ? "Speichern ..." : "Speichern"}
</button>
</div>
</div>
);
}

View File

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

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

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

21
frontend/tsconfig.json Normal file
View File

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

View File

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

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