Compare commits

...

22 Commits

Author SHA1 Message Date
eb0f921464 feat: Add invoice management menu and template editor for admin
- Add 'Rechnung' menu with sub-items 'Verwalten' and 'Template' in admin sidebar
- Create InvoiceTemplatePage with drag-and-drop editor for invoice templates
  - Includes invoice-specific elements (header, customer data, issuer info,
    invoice items, totals, payment terms, bank details)
  - Supports PDF preview and download
  - API integration for saving/loading templates (/admin/invoice-template)
- Create InvoiceManagementPage as placeholder for invoice overview
- Add routes for /admin/rechnung/verwalten and /admin/rechnung/template
- Update page titles in AppShell for new routes
2026-03-16 20:30:45 +01:00
cbabe13162 Admin Dashboard: Verwaltungsmodul-Bereich entfernt 2026-03-16 20:19:26 +01:00
538ec2419d AdminStatisticsService korrigiert: Proben werden jetzt basierend auf ownerAccountId und createdByUserCode korrekt zugeordnet 2026-03-16 20:17:27 +01:00
19fda276b0 Admin Dashboard mit Chart.js: Balkendiagramm zeigt Proben pro Tierarzt 2026-03-16 17:14:17 +01:00
1df2d8276c Admin Dashboard mit Statistiken: Tierärzte-Anzahl, Gesamtproben und Proben pro Tierarzt 2026-03-16 17:11:17 +01:00
2fd101565e Admin wird in der Benutzerverwaltung nicht mehr angezeigt 2026-03-16 17:04:27 +01:00
021730b90b UserManagementPage für Admin vereinfacht: Nur Hauptnutzer anzeigen mit Freigabe/Sperre-Funktion 2026-03-16 17:02:19 +01:00
2f9b12250f Admin Dashboard weiter reduziert: Statistik-Karten entfernt, nur noch Header und Benutzerverwaltung 2026-03-16 16:58:56 +01:00
89d6651af2 Admin Dashboard reduziert: Nur noch Statistiken und Benutzerverwaltung-Kachel 2026-03-16 16:56:55 +01:00
118e6431da Admin-Menü weiter reduziert: Nur noch Dashboard und Benutzerverwaltung (Freigabe/Sperre) 2026-03-16 16:54:57 +01:00
477fcb69c4 Admin-Menü reduziert: Nur noch Dashboard, Neue Probe, Benutzerverwaltung (Freigabe/Sperre) und Portal 2026-03-16 16:53:50 +01:00
40de46588e Admin Dashboard hinzugefügt: Modernes Dashboard für Administratoren mit Statistiken, Verwaltungsmodulen und Schnellzugriffen 2026-03-16 16:51:15 +01:00
2deafd219b Projekt auf Java 21 umgestellt 2026-03-16 12:43:21 +01:00
c41cdad90d UI-Verbesserungen: Rechnungsmenü entfernt, TS zu Trockenstellerprobe, Auffällig statt ⚠, Erregerkacheln bereinigt, Layout-Abstände optimiert 2026-03-16 12:38:08 +01:00
e01afb9a10 Add report templates and unify template storage 2026-03-13 19:20:54 +01:00
5fd349dee2 Refine invoice template editor interactions 2026-03-13 16:59:14 +01:00
490be6a89b Add persistent invoice template management 2026-03-13 16:02:46 +01:00
ff237332e1 Add invoice template editor 2026-03-13 11:35:15 +01:00
c7362a553b Harden access control and restore customer admin pages 2026-03-13 08:52:04 +01:00
eb699666d9 Harden auth and improve user management 2026-03-12 20:28:06 +01:00
1a8e37bd36 Add customer search and navigation updates 2026-03-12 16:50:50 +01:00
8ebb4d06e5 Add customer registration and deployment updates 2026-03-12 16:02:16 +01:00
65 changed files with 8878 additions and 795 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
.DS_Store
.vscode
backend/target
frontend/dist
frontend/node_modules

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ backend/target/
frontend/node_modules/
frontend/dist/
.DS_Store
.env

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:20-alpine AS frontend-build
WORKDIR /build/frontend
ARG VITE_API_URL=/api
ENV VITE_API_URL=${VITE_API_URL}
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM maven:3.9.11-eclipse-temurin-21 AS backend-build
WORKDIR /build/backend
COPY backend/pom.xml ./
RUN mvn -B -q -DskipTests dependency:go-offline
COPY backend/ ./
COPY --from=frontend-build /build/frontend/dist ./src/main/resources/static
RUN mvn -B -q -DskipTests package
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
COPY --from=backend-build /build/backend/target/muh-backend-0.0.1-SNAPSHOT.jar /app/app.jar
USER spring:spring
EXPOSE 8090
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

View File

@@ -10,18 +10,26 @@ Therapieempfehlungen sowie Verwaltungs- und Portalaufgaben.
## Konfiguration
MongoDB ist bereits im Backend vorkonfiguriert:
Die Anwendung liest Konfigurationswerte aus einer `.env` im Projektverzeichnis
oder aus Umgebungsvariablen.
- `mongodb://192.168.180.25:27017/muh`
Die lokale `.env` ist in `.gitignore` eingetragen und sollte nicht mit echten Zugangsdaten committed werden.
MongoDB:
- `MUH_MONGODB_URL`
- `MUH_MONGODB_USERNAME`
- `MUH_MONGODB_PASSWORD`
Optional fuer echten Mailversand im Portal:
- `MUH_MAIL_ENABLED=true`
- `MUH_MAIL_FROM=...`
- `MUH_MAIL_HOST=...`
- `MUH_MAIL_PORT=587`
- `MUH_MAIL_USERNAME=...`
- `MUH_MAIL_PASSWORD=...`
- `MUH_MAIL_FROM=...`
- `MUH_MAIL_PROTOCOL=smtp`
- `MUH_MAIL_AUTH=true`
- `MUH_MAIL_STARTTLS=true`
@@ -52,13 +60,34 @@ Frontend-URL:
Optional kann die API-URL im Frontend ueber `VITE_API_URL` gesetzt werden.
## Docker Deployment
Produktions-Image bauen:
```bash
docker build -t muh-app .
```
Container starten:
```bash
docker run --rm --env-file .env -p 8090:8090 muh-app
```
Die Anwendung ist danach unter `http://localhost:8090` erreichbar.
Hinweis:
- Das Dockerfile baut das React-Frontend und das Spring-Boot-Backend in einem Image.
- Das Frontend wird im Container direkt ueber Spring Boot ausgeliefert.
- API-Aufrufe laufen in Produktion relativ ueber `/api`.
## Anmeldung
Es gibt jetzt drei Varianten:
Es gibt jetzt zwei Varianten:
- Schnelllogin ueber Benutzerkuerzel
- Login ueber E-Mail oder Benutzername plus Passwort
- Registrierung eines neuen Kundenkontos ueber Firmenname, Adresse, E-Mail und Passwort
- Registrierung eines neuen Kundenkontos ueber Firmenname, Strasse, Hausnummer, PLZ, Ort, E-Mail und Passwort
Vordefinierter Admin:

View File

@@ -17,7 +17,7 @@
<description>MUH application backend</description>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
</properties>
<dependencies>
@@ -37,6 +37,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>

View File

@@ -0,0 +1,37 @@
package de.svencarstensen.muh.config;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.MongoCredential;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
@Configuration
public class MongoConfig {
@Bean
MongoClient mongoClient(
@Value("${muh.mongodb.url}") String mongoUrl,
@Value("${muh.mongodb.username:}") String username,
@Value("${muh.mongodb.password:}") String password
) {
ConnectionString connectionString = new ConnectionString(mongoUrl);
MongoClientSettings.Builder builder = MongoClientSettings.builder()
.applyConnectionString(connectionString);
if (StringUtils.hasText(username)) {
String authDatabase = connectionString.getDatabase() == null ? "admin" : connectionString.getDatabase();
builder.credential(MongoCredential.createCredential(
username.trim(),
authDatabase,
password == null ? new char[0] : password.toCharArray()
));
}
return MongoClients.create(builder.build());
}
}

View File

@@ -8,12 +8,17 @@ import java.time.LocalDateTime;
@Document("users")
public record AppUser(
@Id String id,
String code,
String accountId,
Boolean primaryUser,
String displayName,
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String portalLogin,
String phoneNumber,
String passwordHash,
boolean active,
UserRole role,

View File

@@ -0,0 +1,16 @@
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("invoiceTemplates")
public record InvoiceTemplate(
@Id String userId,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,21 @@
package de.svencarstensen.muh.domain;
public record InvoiceTemplateElement(
String id,
String paletteId,
String kind,
String label,
String content,
Integer x,
Integer y,
Integer width,
Integer height,
Integer fontSize,
Integer fontWeight,
String textAlign,
String lineOrientation,
String imageSrc,
Integer imageNaturalWidth,
Integer imageNaturalHeight
) {
}

View File

@@ -0,0 +1,16 @@
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("reportTemplates")
public record ReportTemplate(
@Id String userId,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -27,6 +27,7 @@ public record Sample(
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime completedAt,
String ownerAccountId,
String createdByUserCode,
String createdByDisplayName
) {

View File

@@ -0,0 +1,20 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
@Document("templates")
@CompoundIndex(name = "user_template_type_unique", def = "{'userId': 1, 'type': 1}", unique = true)
public record Template(
@Id String id,
String userId,
TemplateType type,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,6 @@
package de.svencarstensen.muh.domain;
public enum TemplateType {
INVOICE,
REPORT
}

View File

@@ -9,9 +9,7 @@ import java.util.Optional;
public interface AppUserRepository extends MongoRepository<AppUser, String> {
List<AppUser> findByActiveTrueOrderByDisplayNameAsc();
Optional<AppUser> findByCodeIgnoreCase(String code);
List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
Optional<AppUser> findByEmailIgnoreCase(String email);
Optional<AppUser> findByPortalLoginIgnoreCase(String portalLogin);
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.InvoiceTemplate;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface InvoiceTemplateRepository extends MongoRepository<InvoiceTemplate, String> {
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.ReportTemplate;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface ReportTemplateRepository extends MongoRepository<ReportTemplate, String> {
}

View File

@@ -16,6 +16,8 @@ public interface SampleRepository extends MongoRepository<Sample, String> {
List<Sample> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey);
List<Sample> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime start, LocalDateTime end);
List<Sample> findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end);
List<Sample> findByCompletedAtNotNullOrderByCompletedAtDesc();

View File

@@ -0,0 +1,12 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;
public interface TemplateRepository extends MongoRepository<Template, String> {
Optional<Template> findByUserIdAndType(String userId, TemplateType type);
}

View File

@@ -0,0 +1,84 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.UserRole;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.Base64;
@Service
public class AuthTokenService {
private final byte[] secret;
private final long validitySeconds;
public AuthTokenService(
@Value("${muh.security.token-secret}") String secret,
@Value("${muh.security.token-validity-hours:12}") long validityHours
) {
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("MUH_TOKEN_SECRET muss gesetzt sein");
}
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.validitySeconds = Math.max(1, validityHours) * 3600L;
}
public String createToken(AppUser user) {
long expiresAt = Instant.now().plusSeconds(validitySeconds).getEpochSecond();
String payload = user.id() + "|" + user.role().name() + "|" + expiresAt;
return encode(payload) + "." + sign(payload);
}
public AuthenticatedUser parseToken(String token) {
String[] parts = token.split("\\.");
if (parts.length != 2) {
throw new IllegalArgumentException("Token-Format ungueltig");
}
String payload = decode(parts[0]);
String expectedSignature = sign(payload);
if (!expectedSignature.equals(parts[1])) {
throw new IllegalArgumentException("Token-Signatur ungueltig");
}
String[] payloadParts = payload.split("\\|");
if (payloadParts.length != 3) {
throw new IllegalArgumentException("Token-Inhalt ungueltig");
}
long expiresAt = Long.parseLong(payloadParts[2]);
if (Instant.now().getEpochSecond() > expiresAt) {
throw new IllegalArgumentException("Token abgelaufen");
}
return new AuthenticatedUser(payloadParts[0], "", UserRole.valueOf(payloadParts[1]));
}
private String sign(String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return encode(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Token-Signatur konnte nicht erzeugt werden", exception);
}
}
private String encode(byte[] value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
}
private String encode(String value) {
return encode(value.getBytes(StandardCharsets.UTF_8));
}
private String decode(String value) {
return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,6 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.UserRole;
public record AuthenticatedUser(String id, String displayName, UserRole role) {
}

View File

@@ -0,0 +1,56 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.UserRole;
import de.svencarstensen.muh.repository.AppUserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Objects;
@Service
public class AuthorizationService {
private final AppUserRepository appUserRepository;
public AuthorizationService(AppUserRepository appUserRepository) {
this.appUserRepository = appUserRepository;
}
public AppUser requireActiveUser(String actorId, String message) {
return appUserRepository.findById(requireText(actorId, message))
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, message));
}
public void requireAdmin(String actorId, String message) {
AppUser actor = requireActiveUser(actorId, message);
if (!isAdmin(actor)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, message);
}
}
public boolean isAdmin(AppUser user) {
return user.role() == UserRole.ADMIN;
}
public String accountId(AppUser user) {
if (user.accountId() == null || user.accountId().isBlank()) {
if (user.id() == null || user.id().isBlank()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Benutzerkonto ungueltig");
}
return user.id().trim();
}
return user.accountId().trim();
}
private @NonNull String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
String sanitized = Objects.requireNonNull(value).trim();
return Objects.requireNonNull(sanitized);
}
}

View File

@@ -0,0 +1,64 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.repository.AppUserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
@Component
public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
private final AuthTokenService authTokenService;
private final AppUserRepository appUserRepository;
public BearerTokenAuthenticationFilter(AuthTokenService authTokenService, AppUserRepository appUserRepository) {
this.authTokenService = authTokenService;
this.appUserRepository = appUserRepository;
}
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
)
throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
try {
AuthenticatedUser tokenUser = authTokenService.parseToken(authorization.substring(7));
AppUser user = appUserRepository.findById(Objects.requireNonNull(tokenUser.id()))
.filter(AppUser::active)
.orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig"));
AuthenticatedUser principal = new AuthenticatedUser(user.id(), user.displayName(), user.role());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + user.role().name()))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (RuntimeException exception) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,34 @@
package de.svencarstensen.muh.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, BearerTokenAuthenticationFilter bearerTokenAuthenticationFilter)
throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(bearerTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.cors(Customizer.withDefaults());
return http.build();
}
}

View File

@@ -0,0 +1,17 @@
package de.svencarstensen.muh.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component
public class SecuritySupport {
public AuthenticatedUser currentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof AuthenticatedUser principal)) {
throw new IllegalStateException("Kein authentifizierter Benutzer vorhanden");
}
return principal;
}
}

View File

@@ -0,0 +1,70 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.Sample;
import de.svencarstensen.muh.domain.UserRole;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.SampleRepository;
import de.svencarstensen.muh.web.dto.AdminStatistics;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AdminStatisticsService {
private final AppUserRepository appUserRepository;
private final SampleRepository sampleRepository;
public AdminStatisticsService(AppUserRepository appUserRepository, SampleRepository sampleRepository) {
this.appUserRepository = appUserRepository;
this.sampleRepository = sampleRepository;
}
public AdminStatistics getStatistics() {
// Alle Hauptnutzer (Tierärzte) laden - primaryUser=true und role=CUSTOMER
List<AppUser> vets = appUserRepository.findAll().stream()
.filter(u -> Boolean.TRUE.equals(u.primaryUser()))
.filter(u -> u.role() == UserRole.CUSTOMER)
.toList();
// Alle Proben laden
List<Sample> allSamples = sampleRepository.findAll();
// Proben pro Tierarzt zählen (basierend auf ownerAccountId oder createdByUserCode)
List<AdminStatistics.VetSampleStats> samplesPerVet = vets.stream()
.map(vet -> {
String vetId = vet.id();
String accountId = vet.accountId();
long sampleCount = allSamples.stream()
.filter(s -> {
// Prüfe sowohl ownerAccountId als auch createdByUserCode
String ownerId = s.ownerAccountId();
String creatorId = s.createdByUserCode();
// Vergleiche mit vet.id() oder vet.accountId()
return vetId.equals(ownerId) ||
vetId.equals(creatorId) ||
accountId != null && accountId.equals(ownerId) ||
accountId != null && accountId.equals(creatorId);
})
.count();
return new AdminStatistics.VetSampleStats(
vet.id(),
vet.displayName(),
vet.companyName(),
sampleCount
);
})
.filter(s -> s.sampleCount() > 0)
.sorted((a, b) -> Long.compare(b.sampleCount(), a.sampleCount()))
.toList();
return new AdminStatistics(
vets.size(),
allSamples.size(),
samplesPerVet
);
}
}

View File

@@ -13,21 +13,26 @@ 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 de.svencarstensen.muh.security.AuthTokenService;
import de.svencarstensen.muh.security.AuthorizationService;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
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.Arrays;
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;
@@ -60,6 +65,9 @@ public class CatalogService {
private final PathogenCatalogRepository pathogenRepository;
private final AntibioticCatalogRepository antibioticRepository;
private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService;
private final AuthorizationService authorizationService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService(
@@ -67,13 +75,19 @@ public class CatalogService {
MedicationCatalogRepository medicationRepository,
PathogenCatalogRepository pathogenRepository,
AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository
AppUserRepository appUserRepository,
MongoTemplate mongoTemplate,
AuthTokenService authTokenService,
AuthorizationService authorizationService
) {
this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository;
this.pathogenRepository = pathogenRepository;
this.antibioticRepository = antibioticRepository;
this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService;
this.authorizationService = authorizationService;
}
public ActiveCatalogSummary activeCatalogSummary() {
@@ -82,11 +96,12 @@ public class CatalogService {
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()
List.of()
);
}
public AdministrationOverview administrationOverview() {
public AdministrationOverview administrationOverview(String actorId) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows());
}
@@ -118,7 +133,8 @@ public class CatalogService {
.toList();
}
public List<FarmerRow> saveFarmers(List<FarmerMutation> mutations) {
public List<FarmerRow> saveFarmers(String actorId, List<FarmerMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (FarmerMutation mutation : mutations) {
if (isBlank(mutation.name())) {
continue;
@@ -181,7 +197,8 @@ public class CatalogService {
return listFarmerRows();
}
public List<MedicationRow> saveMedications(List<MedicationMutation> mutations) {
public List<MedicationRow> saveMedications(String actorId, List<MedicationMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (MedicationMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.category() == null) {
continue;
@@ -244,7 +261,8 @@ public class CatalogService {
return listMedicationRows();
}
public List<PathogenRow> savePathogens(List<PathogenMutation> mutations) {
public List<PathogenRow> savePathogens(String actorId, List<PathogenMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (PathogenMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.kind() == null) {
continue;
@@ -312,7 +330,8 @@ public class CatalogService {
return listPathogenRows();
}
public List<AntibioticRow> saveAntibiotics(List<AntibioticMutation> mutations) {
public List<AntibioticRow> saveAntibiotics(String actorId, List<AntibioticMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
for (AntibioticMutation mutation : mutations) {
if (isBlank(mutation.name())) {
continue;
@@ -375,32 +394,51 @@ public class CatalogService {
return listAntibioticRows();
}
public List<UserRow> listUsers() {
public List<UserRow> listUsers(String actorId) {
AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
ensureDefaultUsers();
return appUserRepository.findAll().stream()
List<AppUser> users = actor.role() == UserRole.ADMIN
? appUserRepository.findAll()
: appUserRepository.findByAccountIdOrderByDisplayNameAsc(resolveAccountId(actor));
return users.stream()
.map(this::toUserRow)
.sorted(Comparator.comparing(UserRow::active).reversed().thenComparing(UserRow::displayName, String.CASE_INSENSITIVE_ORDER))
.sorted(Comparator.comparing(UserRow::primaryUser).reversed()
.thenComparing(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");
public UserRow createOrUpdateUser(String actorId, UserMutation mutation) {
AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
if (isBlank(mutation.displayName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername ist erforderlich");
}
LocalDateTime now = LocalDateTime.now();
validateUserMutation(mutation);
validateUserMutation(actor, mutation);
if (isBlank(mutation.id())) {
if (isBlank(mutation.email()) || isBlank(mutation.password())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "E-Mail und Passwort sind erforderlich");
}
String userId = UUID.randomUUID().toString();
boolean adminManaged = actor.role() == UserRole.ADMIN;
AppUser created = appUserRepository.save(new AppUser(
null,
mutation.code().trim().toUpperCase(),
userId,
adminManaged ? userId : resolveAccountId(actor),
adminManaged,
mutation.displayName().trim(),
blankToNull(mutation.companyName()),
blankToNull(mutation.address()),
adminManaged ? blankToNull(mutation.companyName()) : null,
adminManaged
? buildAddress(mutation.street(), mutation.houseNumber(), mutation.postalCode(), mutation.city())
: null,
adminManaged ? blankToNull(mutation.street()) : null,
adminManaged ? blankToNull(mutation.houseNumber()) : null,
adminManaged ? blankToNull(mutation.postalCode()) : null,
adminManaged ? blankToNull(mutation.city()) : null,
normalizeEmail(mutation.email()),
blankToNull(mutation.portalLogin()),
adminManaged ? blankToNull(mutation.phoneNumber()) : null,
encodeIfPresent(mutation.password()),
mutation.active(),
Optional.ofNullable(mutation.role()).orElse(UserRole.APP),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
now,
now
));
@@ -409,41 +447,73 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt");
AppUser existing = appUserRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
requireUserManagementAccess(actor, existing, true);
AppUser saved = appUserRepository.save(new AppUser(
existing.id(),
mutation.code().trim().toUpperCase(),
existing.accountId(),
isPrimaryUser(existing),
mutation.displayName().trim(),
blankToNull(mutation.companyName()),
blankToNull(mutation.address()),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.companyName()) : existing.companyName(),
buildAddress(
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.street() : existing.street(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.houseNumber() : existing.houseNumber(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.postalCode() : existing.postalCode(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.city() : existing.city()
),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.street()) : existing.street(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.houseNumber()) : existing.houseNumber(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.postalCode()) : existing.postalCode(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.city()) : existing.city(),
normalizeEmail(mutation.email()),
blankToNull(mutation.portalLogin()),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.phoneNumber()) : existing.phoneNumber(),
isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(),
Optional.ofNullable(mutation.role()).orElse(existing.role()),
actor.role() == UserRole.ADMIN
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()),
existing.createdAt(),
now
));
return toUserRow(saved);
}
public void deleteUser(String id) {
appUserRepository.deleteById(requireText(id, "Benutzer-ID fehlt"));
public void deleteUser(String actorId, String id) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
AppUser existing = appUserRepository.findById(requireText(id, "Benutzer-ID fehlt"))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
requireUserManagementAccess(actor, existing, false);
if (isPrimaryUser(existing) && actor.role() != UserRole.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Der Hauptbenutzer kann nicht geloescht werden");
}
appUserRepository.deleteById(requireText(existing.id(), "Benutzer-ID fehlt"));
}
public void changePassword(String id, String newPassword) {
public void changePassword(String actorId, 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"))
String targetUserId = requireText(id, "Benutzer-ID fehlt");
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
AppUser existing = appUserRepository.findById(targetUserId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
if (actor.role() != UserRole.ADMIN
&& !actor.id().equals(existing.id())
&& !resolveAccountId(actor).equals(resolveAccountId(existing))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
appUserRepository.save(new AppUser(
existing.id(),
existing.code(),
existing.accountId(),
isPrimaryUser(existing),
existing.displayName(),
existing.companyName(),
existing.address(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(),
existing.portalLogin(),
existing.phoneNumber(),
passwordEncoder.encode(newPassword),
existing.active(),
existing.role(),
@@ -452,29 +522,31 @@ public class CatalogService {
));
}
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");
public SessionResponse loginWithPassword(String email, String password) {
if (isBlank(email) || isBlank(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "E-Mail und Passwort sind erforderlich");
}
AppUser user = resolvePasswordUser(identifier.trim())
AppUser user = resolvePasswordUser(email.trim())
.filter(AppUser::active)
.filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash()))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Anmeldung fehlgeschlagen"));
return toUserOption(user);
return toSessionResponse(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");
public SessionResponse registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName())
|| isBlank(mutation.street())
|| isBlank(mutation.houseNumber())
|| isBlank(mutation.postalCode())
|| isBlank(mutation.city())
|| isBlank(mutation.email())
|| isBlank(mutation.phoneNumber())
|| isBlank(mutation.password())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Firmenname, Strasse, Hausnummer, PLZ, Ort, E-Mail, Telefonnummer und Passwort sind erforderlich"
);
}
String normalizedEmail = normalizeEmail(mutation.email());
@@ -483,45 +555,69 @@ public class CatalogService {
}
String companyName = mutation.companyName().trim();
String address = mutation.address().trim();
String street = mutation.street().trim();
String houseNumber = mutation.houseNumber().trim();
String postalCode = mutation.postalCode().trim();
String city = mutation.city().trim();
String phoneNumber = mutation.phoneNumber().trim();
String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName;
String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail));
String code = generateUniqueCode("K" + companyName);
LocalDateTime now = LocalDateTime.now();
AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(),
null,
code,
true,
displayName,
companyName,
address,
street,
houseNumber,
postalCode,
city,
normalizedEmail,
portalLogin,
phoneNumber,
passwordEncoder.encode(mutation.password()),
true,
UserRole.CUSTOMER,
now,
now
));
return toUserOption(created);
AppUser accountBound = appUserRepository.save(new AppUser(
created.id(),
created.id(),
true,
created.displayName(),
created.companyName(),
created.address(),
created.street(),
created.houseNumber(),
created.postalCode(),
created.city(),
created.email(),
created.phoneNumber(),
created.passwordHash(),
created.active(),
created.role(),
created.createdAt(),
created.updatedAt()
));
return toSessionResponse(accountBound);
}
private List<AppUser> activeUsers() {
ensureDefaultUsers();
return appUserRepository.findByActiveTrueOrderByDisplayNameAsc();
}
private List<AppUser> activeQuickLoginUsers() {
return activeUsers().stream()
.filter(user -> user.role() != UserRole.CUSTOMER)
.toList();
public UserOption currentUser(String actorId) {
AppUser user = appUserRepository.findById(requireText(actorId, "Benutzer-ID fehlt"))
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Benutzer nicht authentifiziert"));
return toUserOption(user);
}
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);
migrateLegacyAppUsers();
removeLegacyUserCodeField();
backfillDefaultUserEmails();
removeLegacyPortalLoginField();
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
}
public Farmer requireActiveFarmer(String businessKey) {
@@ -594,14 +690,18 @@ public class CatalogService {
private UserRow toUserRow(AppUser user) {
return new UserRow(
user.id(),
user.code(),
isPrimaryUser(user),
user.displayName(),
user.companyName(),
user.address(),
resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.portalLogin(),
user.phoneNumber(),
user.active(),
user.role(),
normalizeStoredRole(user.role()),
user.updatedAt()
);
}
@@ -625,16 +725,24 @@ public class CatalogService {
private UserOption toUserOption(AppUser user) {
return new UserOption(
user.id(),
user.code(),
isPrimaryUser(user),
user.displayName(),
user.companyName(),
user.address(),
resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.portalLogin(),
user.role()
user.phoneNumber(),
normalizeStoredRole(user.role())
);
}
private SessionResponse toSessionResponse(AppUser user) {
return new SessionResponse(authTokenService.createToken(user), toUserOption(user));
}
private String encodeIfPresent(String password) {
return isBlank(password) ? null : passwordEncoder.encode(password);
}
@@ -647,29 +755,16 @@ public class CatalogService {
return Objects.requireNonNull(sanitized);
}
private void validateUserMutation(UserMutation mutation) {
private void validateUserMutation(AppUser actor, 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");
}
});
}
@@ -685,35 +780,87 @@ public class CatalogService {
return left == null ? right == null : left.equals(right);
}
private Optional<AppUser> resolvePasswordUser(String identifier) {
return appUserRepository.findByEmailIgnoreCase(identifier)
.or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier));
private Optional<AppUser> resolvePasswordUser(String email) {
return appUserRepository.findByEmailIgnoreCase(normalizeEmail(email));
}
private AppUser requireActiveActor(String actorId, String message) {
return appUserRepository.findById(requireText(actorId, message))
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, message));
}
private String resolveAccountId(AppUser user) {
return isBlank(user.accountId()) ? requireText(user.id(), "Benutzerkonto ungueltig") : user.accountId();
}
private boolean isPrimaryUser(AppUser user) {
return Boolean.TRUE.equals(user.primaryUser());
}
private void requireUserManagementAccess(AppUser actor, AppUser target, boolean allowPrimaryUserEdit) {
if (actor.role() == UserRole.ADMIN) {
return;
}
if (!resolveAccountId(actor).equals(resolveAccountId(target))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
if (!allowPrimaryUserEdit && isPrimaryUser(target)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Der Hauptbenutzer kann hier nicht bearbeitet werden");
}
}
private void migrateLegacyAppUsers() {
LocalDateTime now = LocalDateTime.now();
appUserRepository.findAll().stream()
.filter(user -> user.role() == UserRole.APP || isBlank(user.accountId()) || user.primaryUser() == null)
.forEach(user -> appUserRepository.save(new AppUser(
user.id(),
user.id(),
true,
user.displayName(),
user.companyName(),
user.address(),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.passwordHash(),
user.active(),
normalizeStoredRole(user.role()),
user.createdAt(),
now
)));
}
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();
boolean exists = appUserRepository.findByEmailIgnoreCase(email).isPresent();
if (exists) {
return;
}
LocalDateTime now = LocalDateTime.now();
String userId = UUID.randomUUID().toString();
appUserRepository.save(new AppUser(
null,
code,
userId,
userId,
true,
displayName,
null,
null,
null,
null,
null,
null,
email,
portalLogin,
null,
passwordEncoder.encode(rawPassword),
true,
role,
@@ -722,56 +869,81 @@ public class CatalogService {
));
}
private void removeLegacyUserCodeField() {
mongoTemplate.updateMulti(
new Query(Criteria.where("code").exists(true)),
new Update().unset("code"),
AppUser.class
);
}
private void removeLegacyPortalLoginField() {
mongoTemplate.updateMulti(
new Query(Criteria.where("portalLogin").exists(true)),
new Update().unset("portalLogin"),
AppUser.class
);
}
private void backfillDefaultUserEmails() {
backfillDefaultUserEmail("admin", "admin@muh.local");
}
private void backfillDefaultUserEmail(String legacyPortalLogin, String email) {
mongoTemplate.updateMulti(
new Query(new Criteria().andOperator(
new Criteria().orOperator(
Criteria.where("email").exists(false),
Criteria.where("email").is(null),
Criteria.where("email").is("")
),
Criteria.where("portalLogin").is(legacyPortalLogin)
)),
new Update().set("email", email),
AppUser.class
);
}
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 UserRole normalizeStoredRole(UserRole role) {
return role == null || role == UserRole.APP ? UserRole.CUSTOMER : role;
}
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 UserRole normalizeManagedRole(UserRole role) {
return role == null || role == UserRole.APP ? UserRole.CUSTOMER : role;
}
private String generateUniqueCode(String seed) {
String compact = seed.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
if (compact.isBlank()) {
compact = "USR";
private String resolveAddress(AppUser user) {
if (!isBlank(user.address())) {
return user.address();
}
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;
if (isBlank(user.street()) && isBlank(user.houseNumber()) && isBlank(user.postalCode()) && isBlank(user.city())) {
return null;
}
return formatAddress(user.street(), user.houseNumber(), user.postalCode(), user.city());
}
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++;
private String buildAddress(String street, String houseNumber, String postalCode, String city) {
if (isBlank(street) && isBlank(houseNumber) && isBlank(postalCode) && isBlank(city)) {
return null;
}
return formatAddress(street, houseNumber, postalCode, city);
}
private String formatAddress(String street, String houseNumber, String postalCode, String city) {
String streetPart = joinParts(" ", street, houseNumber);
String cityPart = joinParts(" ", postalCode, city);
return joinParts(", ", streetPart, cityPart);
}
private String joinParts(String separator, String... parts) {
return Arrays.stream(parts)
.filter(part -> !isBlank(part))
.map(String::trim)
.collect(Collectors.joining(separator));
}
public record ActiveCatalogSummary(
@@ -805,12 +977,16 @@ public class CatalogService {
public record UserOption(
String id,
String code,
boolean primaryUser,
String displayName,
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String portalLogin,
String phoneNumber,
UserRole role
) {
}
@@ -870,12 +1046,16 @@ public class CatalogService {
public record UserRow(
String id,
String code,
boolean primaryUser,
String displayName,
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String portalLogin,
String phoneNumber,
boolean active,
UserRole role,
LocalDateTime updatedAt
@@ -884,12 +1064,15 @@ public class CatalogService {
public record UserMutation(
String id,
String code,
String displayName,
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String portalLogin,
String phoneNumber,
String password,
boolean active,
UserRole role
@@ -898,9 +1081,16 @@ public class CatalogService {
public record RegistrationMutation(
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
String password
) {
}
public record SessionResponse(String token, UserOption user) {
}
}

View File

@@ -0,0 +1,208 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.InvoiceTemplate;
import de.svencarstensen.muh.domain.InvoiceTemplateElement;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.InvoiceTemplateRepository;
import de.svencarstensen.muh.repository.TemplateRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@Service
public class InvoiceTemplateService {
private static final TemplateType TEMPLATE_TYPE = TemplateType.INVOICE;
private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final InvoiceTemplateRepository invoiceTemplateRepository;
public InvoiceTemplateService(
AppUserRepository appUserRepository,
TemplateRepository templateRepository,
InvoiceTemplateRepository invoiceTemplateRepository
) {
this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.invoiceTemplateRepository = invoiceTemplateRepository;
}
public InvoiceTemplateResponse currentTemplate(String actorId) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
return templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE)
.map(this::toResponse)
.or(() -> invoiceTemplateRepository.findById(userId).map(this::toLegacyResponse))
.orElseGet(() -> new InvoiceTemplateResponse(false, List.of(), null));
}
public InvoiceTemplateResponse saveTemplate(String actorId, List<InvoiceTemplateElementPayload> payloadElements) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
Template existing = templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE).orElse(null);
InvoiceTemplate legacyTemplate = existing == null
? invoiceTemplateRepository.findById(userId).orElse(null)
: null;
LocalDateTime now = LocalDateTime.now();
Template saved = templateRepository.save(new Template(
existing != null ? existing.id() : templateId(userId),
userId,
TEMPLATE_TYPE,
sanitizeElements(payloadElements),
existing != null ? existing.createdAt() : legacyTemplate != null ? legacyTemplate.createdAt() : now,
now
));
return toResponse(saved);
}
private AppUser requireActiveUser(@NonNull String actorId) {
return appUserRepository.findById(actorId)
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt"));
}
private @NonNull String requireActorId(String actorId) {
if (isBlank(actorId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
String sanitized = Objects.requireNonNull(actorId).trim();
return Objects.requireNonNull(sanitized);
}
private List<InvoiceTemplateElement> sanitizeElements(List<InvoiceTemplateElementPayload> payloadElements) {
if (payloadElements == null || payloadElements.isEmpty()) {
return List.of();
}
return payloadElements.stream()
.map(this::sanitizeElement)
.filter(Objects::nonNull)
.toList();
}
private InvoiceTemplateElement sanitizeElement(InvoiceTemplateElementPayload payload) {
if (payload == null
|| isBlank(payload.id())
|| isBlank(payload.paletteId())
|| isBlank(payload.kind())
|| payload.x() == null
|| payload.y() == null
|| payload.width() == null
|| payload.fontSize() == null) {
return null;
}
return new InvoiceTemplateElement(
payload.id().trim(),
payload.paletteId().trim(),
payload.kind().trim(),
trimOrEmpty(payload.label()),
nullToEmpty(payload.content()),
payload.x(),
payload.y(),
payload.width(),
payload.height(),
payload.fontSize(),
payload.fontWeight(),
blankToNull(payload.textAlign()),
blankToNull(payload.lineOrientation()),
blankToNull(payload.imageSrc()),
payload.imageNaturalWidth(),
payload.imageNaturalHeight()
);
}
private InvoiceTemplateResponse toResponse(Template template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateResponse toLegacyResponse(InvoiceTemplate template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) {
return new InvoiceTemplateElementPayload(
element.id(),
element.paletteId(),
element.kind(),
element.label(),
element.content(),
element.x(),
element.y(),
element.width(),
element.height(),
element.fontSize(),
element.fontWeight(),
element.textAlign(),
element.lineOrientation(),
element.imageSrc(),
element.imageNaturalWidth(),
element.imageNaturalHeight()
);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return isBlank(value) ? null : value.trim();
}
private String trimOrEmpty(String value) {
return value == null ? "" : value.trim();
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private InvoiceTemplateResponse toResponse(List<InvoiceTemplateElement> elements, LocalDateTime updatedAt) {
List<InvoiceTemplateElementPayload> payloads = elements == null
? List.of()
: elements.stream().map(this::toPayload).toList();
return new InvoiceTemplateResponse(true, payloads, updatedAt);
}
private String templateId(String userId) {
return userId + ":" + TEMPLATE_TYPE.name().toLowerCase(Locale.ROOT);
}
public record InvoiceTemplateElementPayload(
String id,
String paletteId,
String kind,
String label,
String content,
Integer x,
Integer y,
Integer width,
Integer height,
Integer fontSize,
Integer fontWeight,
String textAlign,
String lineOrientation,
String imageSrc,
Integer imageNaturalWidth,
Integer imageNaturalHeight
) {
}
public record InvoiceTemplateResponse(
boolean stored,
List<InvoiceTemplateElementPayload> elements,
LocalDateTime updatedAt
) {
}
}

View File

@@ -21,27 +21,35 @@ public class PortalService {
this.catalogService = catalogService;
}
public PortalSnapshot snapshot(String farmerBusinessKey, String farmerQuery, String cowQuery, Long sampleNumber, LocalDate date) {
public PortalSnapshot snapshot(
boolean includeUsers,
String actorId,
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)));
sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(actorId, sampleNumber)));
} else if (farmerBusinessKey != null && !farmerBusinessKey.isBlank()) {
sampleRows = sampleService.samplesByFarmerBusinessKey(farmerBusinessKey).stream()
sampleRows = sampleService.samplesByFarmerBusinessKey(actorId, 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()
sampleRows = sampleService.samplesByDate(actorId, date).stream()
.map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.toList();
} else {
sampleRows = sampleService.completedSamples().stream()
sampleRows = sampleService.completedSamples(actorId).stream()
.limit(25)
.map(this::toPortalRow)
.toList();
@@ -50,11 +58,18 @@ public class PortalService {
return new PortalSnapshot(
matchingFarmers,
sampleRows,
reportService.reportCandidates(),
catalogService.listUsers()
reportService.reportCandidates(actorId),
includeUsers ? catalogService.listUsers(actorId) : List.of()
);
}
public List<PortalSampleRow> searchSamplesByCreatedDate(String actorId, LocalDate date) {
return sampleService.samplesByCreatedDate(actorId, date).stream()
.map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
.toList();
}
private boolean cowMatches(Sample sample, String cowQuery) {
String query = cowQuery.toLowerCase(Locale.ROOT);
return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query))

View File

@@ -1,57 +1,72 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.Sample;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.repository.AppUserRepository;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
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.util.FileCopyUtils;
import org.springframework.web.server.ResponseStatusException;
import jakarta.mail.internet.MimeMessage;
import java.io.IOException;
import java.io.InputStreamReader;
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;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
public class ReportService {
private final SampleService sampleService;
private final AppUserRepository appUserRepository;
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final boolean mailEnabled;
private final String mailFrom;
private final String reportMailTemplate;
public ReportService(
SampleService sampleService,
AppUserRepository appUserRepository,
ObjectProvider<JavaMailSender> mailSenderProvider,
@Value("classpath:mail/report-mail-template.txt") Resource reportMailTemplateResource,
@Value("${muh.mail.enabled:false}") boolean mailEnabled,
@Value("${muh.mail.from:no-reply@muh.local}") String mailFrom
) {
this.sampleService = sampleService;
this.appUserRepository = appUserRepository;
this.mailSenderProvider = mailSenderProvider;
this.mailEnabled = mailEnabled;
this.mailFrom = mailFrom;
this.reportMailTemplate = loadTemplate(reportMailTemplateResource);
}
public List<ReportCandidate> reportCandidates() {
return sampleService.completedSamples().stream()
public List<ReportCandidate> reportCandidates(String actorId) {
return sampleService.completedSamples(actorId).stream()
.filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank())
.map(this::toCandidate)
.toList();
}
public DispatchResult sendReports(List<String> sampleIds) {
public DispatchResult sendReports(String actorId, List<String> sampleIds) {
String customerSignature = buildCustomerSignature(actorId);
List<ReportCandidate> sent = new ArrayList<>();
List<ReportCandidate> skipped = new ArrayList<>();
for (String sampleId : sampleIds) {
Sample sample = sampleService.loadSampleEntity(sampleId);
Sample sample = sampleService.loadSampleEntity(actorId, sampleId);
if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) {
skipped.add(toCandidate(sample));
continue;
@@ -59,7 +74,7 @@ public class ReportService {
byte[] pdf = buildPdf(sample);
if (mailEnabled && mailSenderProvider.getIfAvailable() != null) {
sendMail(sample, pdf);
sendMail(sample, pdf, customerSignature);
}
Sample updated = sampleService.markReportSent(sample.id(), LocalDateTime.now());
sent.add(toCandidate(updated));
@@ -67,15 +82,16 @@ public class ReportService {
return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null);
}
public byte[] reportPdf(String sampleId) {
return buildPdf(sampleService.loadSampleEntity(sampleId));
public byte[] reportPdf(String actorId, String sampleId) {
return buildPdf(sampleService.loadSampleEntity(actorId, sampleId));
}
public SampleService.SampleDetail toggleReportBlocked(String sampleId, boolean blocked) {
return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id());
public SampleService.SampleDetail toggleReportBlocked(String actorId, String sampleId, boolean blocked) {
Sample sample = sampleService.loadSampleEntity(actorId, sampleId);
return sampleService.getSample(actorId, sampleService.toggleReportBlocked(sample.id(), blocked).id());
}
private void sendMail(Sample sample, byte[] pdf) {
private void sendMail(Sample sample, byte[] pdf, String customerSignature) {
try {
JavaMailSender sender = mailSenderProvider.getIfAvailable();
if (sender == null) {
@@ -86,7 +102,7 @@ public class ReportService {
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.setText(Objects.requireNonNull(buildMailBody(sample, customerSignature)), false);
helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf)));
sender.send(message);
} catch (Exception exception) {
@@ -136,6 +152,79 @@ public class ReportService {
return value == null || value.isBlank() ? "-" : value;
}
private String buildMailBody(Sample sample, String customerSignature) {
return reportMailTemplate
.replace("{{sampleNumber}}", String.valueOf(sample.sampleNumber()))
.replace("{{cowLabel}}", resolveCowLabel(sample))
.replace("{{customerSignature}}", customerSignature);
}
private String resolveCowLabel(Sample sample) {
if (sample.cowName() == null || sample.cowName().isBlank()) {
return sample.cowNumber();
}
return sample.cowNumber() + " / " + sample.cowName();
}
private String buildCustomerSignature(String actorId) {
if (actorId == null || actorId.isBlank()) {
return defaultCustomerSignature();
}
String normalizedActorId = Objects.requireNonNull(actorId).trim();
AppUser actor = appUserRepository.findById(Objects.requireNonNull(normalizedActorId)).orElse(null);
if (actor == null) {
return defaultCustomerSignature();
}
String primaryName = firstNonBlank(actor.companyName(), actor.displayName(), "MUH App");
String secondaryName = actor.companyName() != null
&& actor.displayName() != null
&& !actor.companyName().equalsIgnoreCase(actor.displayName())
? actor.displayName()
: null;
String streetLine = joinParts(" ", actor.street(), actor.houseNumber());
String cityLine = joinParts(" ", actor.postalCode(), actor.city());
return Stream.of(
primaryName,
secondaryName,
streetLine,
cityLine,
actor.email(),
actor.phoneNumber()
)
.filter(part -> part != null && !part.isBlank())
.collect(Collectors.joining(System.lineSeparator()));
}
private String defaultCustomerSignature() {
return firstNonBlank(mailFrom, "MUH App");
}
private String joinParts(String separator, String... values) {
return Stream.of(values)
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.collect(Collectors.joining(separator));
}
private String firstNonBlank(String... values) {
return Stream.of(values)
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.findFirst()
.orElse("");
}
private String loadTemplate(Resource resource) {
try (InputStreamReader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) {
return FileCopyUtils.copyToString(reader);
} catch (IOException exception) {
throw new IllegalStateException("Mail-Template konnte nicht geladen werden", exception);
}
}
private @NonNull String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);

View File

@@ -0,0 +1,189 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.InvoiceTemplateElement;
import de.svencarstensen.muh.domain.ReportTemplate;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.ReportTemplateRepository;
import de.svencarstensen.muh.repository.TemplateRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@Service
public class ReportTemplateService {
private static final TemplateType TEMPLATE_TYPE = TemplateType.REPORT;
private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final ReportTemplateRepository reportTemplateRepository;
public ReportTemplateService(
AppUserRepository appUserRepository,
TemplateRepository templateRepository,
ReportTemplateRepository reportTemplateRepository
) {
this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.reportTemplateRepository = reportTemplateRepository;
}
public InvoiceTemplateService.InvoiceTemplateResponse currentTemplate(String actorId) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
return templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE)
.map(this::toResponse)
.or(() -> reportTemplateRepository.findById(userId).map(this::toLegacyResponse))
.orElseGet(() -> new InvoiceTemplateService.InvoiceTemplateResponse(false, List.of(), null));
}
public InvoiceTemplateService.InvoiceTemplateResponse saveTemplate(
String actorId,
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloadElements
) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
Template existing = templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE).orElse(null);
ReportTemplate legacyTemplate = existing == null
? reportTemplateRepository.findById(userId).orElse(null)
: null;
LocalDateTime now = LocalDateTime.now();
Template saved = templateRepository.save(new Template(
existing != null ? existing.id() : templateId(userId),
userId,
TEMPLATE_TYPE,
sanitizeElements(payloadElements),
existing != null ? existing.createdAt() : legacyTemplate != null ? legacyTemplate.createdAt() : now,
now
));
return toResponse(saved);
}
private AppUser requireActiveUser(@NonNull String actorId) {
return appUserRepository.findById(actorId)
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt"));
}
private @NonNull String requireActorId(String actorId) {
if (isBlank(actorId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
String sanitized = Objects.requireNonNull(actorId).trim();
return Objects.requireNonNull(sanitized);
}
private List<InvoiceTemplateElement> sanitizeElements(
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloadElements
) {
if (payloadElements == null || payloadElements.isEmpty()) {
return List.of();
}
return payloadElements.stream()
.map(this::sanitizeElement)
.filter(Objects::nonNull)
.toList();
}
private InvoiceTemplateElement sanitizeElement(InvoiceTemplateService.InvoiceTemplateElementPayload payload) {
if (payload == null
|| isBlank(payload.id())
|| isBlank(payload.paletteId())
|| isBlank(payload.kind())
|| payload.x() == null
|| payload.y() == null
|| payload.width() == null
|| payload.fontSize() == null) {
return null;
}
return new InvoiceTemplateElement(
payload.id().trim(),
payload.paletteId().trim(),
payload.kind().trim(),
trimOrEmpty(payload.label()),
nullToEmpty(payload.content()),
payload.x(),
payload.y(),
payload.width(),
payload.height(),
payload.fontSize(),
payload.fontWeight(),
blankToNull(payload.textAlign()),
blankToNull(payload.lineOrientation()),
blankToNull(payload.imageSrc()),
payload.imageNaturalWidth(),
payload.imageNaturalHeight()
);
}
private InvoiceTemplateService.InvoiceTemplateResponse toResponse(Template template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateService.InvoiceTemplateResponse toLegacyResponse(ReportTemplate template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateService.InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) {
return new InvoiceTemplateService.InvoiceTemplateElementPayload(
element.id(),
element.paletteId(),
element.kind(),
element.label(),
element.content(),
element.x(),
element.y(),
element.width(),
element.height(),
element.fontSize(),
element.fontWeight(),
element.textAlign(),
element.lineOrientation(),
element.imageSrc(),
element.imageNaturalWidth(),
element.imageNaturalHeight()
);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return isBlank(value) ? null : value.trim();
}
private String trimOrEmpty(String value) {
return value == null ? "" : value.trim();
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private InvoiceTemplateService.InvoiceTemplateResponse toResponse(
List<InvoiceTemplateElement> elements,
LocalDateTime updatedAt
) {
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloads = elements == null
? List.of()
: elements.stream().map(this::toPayload).toList();
return new InvoiceTemplateService.InvoiceTemplateResponse(true, payloads, updatedAt);
}
private String templateId(String userId) {
return userId + ":" + TEMPLATE_TYPE.name().toLowerCase(Locale.ROOT);
}
}

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AntibiogramEntry;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.QuarterAntibiogram;
@@ -13,6 +14,8 @@ 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 de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.security.AuthorizationService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@@ -25,33 +28,52 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class SampleService {
private final SampleRepository sampleRepository;
private final CatalogService catalogService;
private final AppUserRepository appUserRepository;
private final AuthorizationService authorizationService;
public SampleService(SampleRepository sampleRepository, CatalogService catalogService) {
public SampleService(
SampleRepository sampleRepository,
CatalogService catalogService,
AppUserRepository appUserRepository,
AuthorizationService authorizationService
) {
this.sampleRepository = sampleRepository;
this.catalogService = catalogService;
this.appUserRepository = appUserRepository;
this.authorizationService = authorizationService;
}
public DashboardOverview dashboardOverview() {
List<SampleSummary> recent = sampleRepository.findTop12ByOrderByUpdatedAtDesc().stream()
public DashboardOverview dashboardOverview(String actorId) {
AppUser actor = requireActor(actorId);
List<Sample> accessibleSamples = accessibleSamples(actor);
List<SampleSummary> recent = accessibleSamples.stream()
.sorted(Comparator.comparing(Sample::updatedAt).reversed())
.limit(12)
.map(this::toSummary)
.toList();
long openCount = sampleRepository.findAll().stream().filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED).count();
long openCount = accessibleSamples.stream()
.filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED)
.count();
LocalDate today = LocalDate.now();
long completedToday = sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(
today.atStartOfDay(),
today.plusDays(1).atStartOfDay()
).size();
long completedToday = accessibleSamples.stream()
.filter(sample -> sample.completedAt() != null)
.filter(sample -> !sample.completedAt().isBefore(today.atStartOfDay()))
.filter(sample -> sample.completedAt().isBefore(today.plusDays(1).atStartOfDay()))
.count();
return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent);
}
public LookupResult lookup(long sampleNumber) {
public LookupResult lookup(String actorId, long sampleNumber) {
AppUser actor = requireActor(actorId);
return sampleRepository.findBySampleNumber(sampleNumber)
.filter(sample -> canAccess(actor, sample))
.map(sample -> new LookupResult(
true,
"Probe gefunden",
@@ -62,17 +84,20 @@ public class SampleService {
.orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null));
}
public SampleDetail getSample(String id) {
return toDetail(loadSample(id));
public SampleDetail getSample(String actorId, String id) {
return toDetail(loadAccessibleSample(actorId, id));
}
public SampleDetail getSampleByNumber(long sampleNumber) {
public SampleDetail getSampleByNumber(String actorId, long sampleNumber) {
AppUser actor = requireActor(actorId);
return sampleRepository.findBySampleNumber(sampleNumber)
.filter(sample -> canAccess(actor, sample))
.map(this::toDetail)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
}
public SampleDetail createSample(RegistrationRequest request) {
public SampleDetail createSample(String actorId, RegistrationRequest request) {
AppUser actor = requireActor(actorId);
LocalDateTime now = LocalDateTime.now();
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
@@ -99,6 +124,7 @@ public class SampleService {
now,
now,
null,
authorizationService.accountId(actor),
request.userCode(),
request.userDisplayName()
);
@@ -106,8 +132,8 @@ public class SampleService {
return toDetail(sampleRepository.save(sample));
}
public SampleDetail saveRegistration(String id, RegistrationRequest request) {
Sample existing = loadSample(id);
public SampleDetail saveRegistration(String actorId, String id, RegistrationRequest request) {
Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditRegistration(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden");
}
@@ -137,6 +163,7 @@ public class SampleService {
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
@@ -144,8 +171,8 @@ public class SampleService {
return toDetail(saved);
}
public SampleDetail saveAnamnesis(String id, AnamnesisRequest request) {
Sample existing = loadSample(id);
public SampleDetail saveAnamnesis(String actorId, String id, AnamnesisRequest request) {
Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditAnamnesis(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden");
}
@@ -203,14 +230,15 @@ public class SampleService {
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
return toDetail(saved);
}
public SampleDetail saveAntibiogram(String id, AntibiogramRequest request) {
Sample existing = loadSample(id);
public SampleDetail saveAntibiogram(String actorId, String id, AntibiogramRequest request) {
Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditAntibiogram(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden");
}
@@ -293,14 +321,15 @@ public class SampleService {
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
return toDetail(saved);
}
public SampleDetail saveTherapy(String id, TherapyRequest request) {
Sample existing = loadSample(id);
public SampleDetail saveTherapy(String actorId, String id, TherapyRequest request) {
Sample existing = loadAccessibleSample(actorId, id);
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
TherapyRecommendation previous = existing.therapyRecommendation();
TherapyRecommendation updated = previous == null
@@ -341,6 +370,7 @@ public class SampleService {
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
)));
@@ -388,6 +418,7 @@ public class SampleService {
existing.createdAt(),
LocalDateTime.now(),
LocalDateTime.now(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
@@ -416,6 +447,7 @@ public class SampleService {
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
@@ -443,21 +475,41 @@ public class SampleService {
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
}
public List<Sample> completedSamples() {
return sampleRepository.findByCompletedAtNotNullOrderByCompletedAtDesc();
public List<Sample> completedSamples(String actorId) {
return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> sample.completedAt() != null)
.sorted(Comparator.comparing(Sample::completedAt).reversed())
.toList();
}
public List<Sample> samplesByFarmerBusinessKey(String businessKey) {
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey);
public List<Sample> samplesByFarmerBusinessKey(String actorId, String businessKey) {
return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> Objects.equals(sample.farmerBusinessKey(), businessKey))
.sorted(Comparator.comparing(Sample::createdAt).reversed())
.toList();
}
public List<Sample> samplesByDate(LocalDate date) {
return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay());
public List<Sample> samplesByCreatedDate(String actorId, LocalDate date) {
return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> !sample.createdAt().isBefore(date.atStartOfDay()))
.filter(sample -> sample.createdAt().isBefore(date.plusDays(1).atStartOfDay()))
.sorted(Comparator.comparing(Sample::createdAt).reversed())
.toList();
}
public List<Sample> samplesByDate(String actorId, LocalDate date) {
return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> sample.completedAt() != null)
.filter(sample -> !sample.completedAt().isBefore(date.atStartOfDay()))
.filter(sample -> sample.completedAt().isBefore(date.plusDays(1).atStartOfDay()))
.sorted(Comparator.comparing(Sample::completedAt).reversed())
.toList();
}
public long nextSampleNumber() {
@@ -466,15 +518,119 @@ public class SampleService {
.orElse(100001L);
}
public Sample loadSampleEntity(String id) {
return loadSample(id);
public Sample loadSampleEntity(String actorId, String id) {
return loadAccessibleSample(actorId, id);
}
private AppUser requireActor(String actorId) {
ensureSampleOwnershipMigration();
return authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
}
private List<Sample> accessibleSamples(AppUser actor) {
List<Sample> samples = sampleRepository.findAll();
if (authorizationService.isAdmin(actor)) {
return samples;
}
String accountId = authorizationService.accountId(actor);
return samples.stream()
.filter(sample -> Objects.equals(sample.ownerAccountId(), accountId))
.toList();
}
private boolean canAccess(AppUser actor, Sample sample) {
return authorizationService.isAdmin(actor)
|| Objects.equals(sample.ownerAccountId(), authorizationService.accountId(actor));
}
private Sample loadAccessibleSample(String actorId, String id) {
AppUser actor = requireActor(actorId);
Sample sample = loadSample(id);
if (!canAccess(actor, sample)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden");
}
return sample;
}
private Sample loadSample(String id) {
ensureSampleOwnershipMigration();
return sampleRepository.findById(Objects.requireNonNull(id))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
}
private void ensureSampleOwnershipMigration() {
List<Sample> samples = sampleRepository.findAll().stream()
.filter(sample -> sample.ownerAccountId() == null || sample.ownerAccountId().isBlank())
.toList();
if (samples.isEmpty()) {
return;
}
List<AppUser> users = appUserRepository.findAll().stream()
.filter(AppUser::active)
.toList();
Map<String, List<AppUser>> usersByDisplayName = users.stream()
.filter(user -> user.displayName() != null && !user.displayName().isBlank())
.collect(Collectors.groupingBy(user -> user.displayName().trim().toLowerCase()));
List<String> primaryCustomerAccounts = users.stream()
.filter(user -> user.role() != de.svencarstensen.muh.domain.UserRole.ADMIN)
.filter(user -> Boolean.TRUE.equals(user.primaryUser()))
.map(authorizationService::accountId)
.distinct()
.toList();
String fallbackAccountId = primaryCustomerAccounts.size() == 1 ? primaryCustomerAccounts.get(0) : null;
for (Sample sample : samples) {
String resolvedAccountId = resolveSampleOwnerAccountId(sample, usersByDisplayName, fallbackAccountId);
if (resolvedAccountId == null) {
continue;
}
sampleRepository.save(new Sample(
sample.id(),
sample.sampleNumber(),
sample.farmerBusinessKey(),
sample.farmerName(),
sample.farmerEmail(),
sample.cowNumber(),
sample.cowName(),
sample.sampleKind(),
sample.samplingMode(),
sample.currentStep(),
sample.quarters(),
sample.antibiograms(),
sample.therapyRecommendation(),
sample.reportSent(),
sample.reportBlocked(),
sample.reportSentAt(),
sample.createdAt(),
sample.updatedAt(),
sample.completedAt(),
resolvedAccountId,
sample.createdByUserCode(),
sample.createdByDisplayName()
));
}
}
private String resolveSampleOwnerAccountId(
Sample sample,
Map<String, List<AppUser>> usersByDisplayName,
String fallbackAccountId
) {
if (sample.createdByDisplayName() != null && !sample.createdByDisplayName().isBlank()) {
List<String> matchingAccounts = usersByDisplayName
.getOrDefault(sample.createdByDisplayName().trim().toLowerCase(), List.of())
.stream()
.map(authorizationService::accountId)
.distinct()
.toList();
if (matchingAccounts.size() == 1) {
return matchingAccounts.get(0);
}
}
return fallbackAccountId;
}
private SampleSummary toSummary(Sample sample) {
return new SampleSummary(
sample.id(),

View File

@@ -0,0 +1,25 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.AdminStatisticsService;
import de.svencarstensen.muh.web.dto.AdminStatistics;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
private final AdminStatisticsService adminStatisticsService;
public AdminController(AdminStatisticsService adminStatisticsService) {
this.adminStatisticsService = adminStatisticsService;
}
@GetMapping("/statistics")
public AdminStatistics getStatistics() {
return adminStatisticsService.getStatistics();
}
}

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -16,9 +17,11 @@ import java.util.List;
public class CatalogController {
private final CatalogService catalogService;
private final SecuritySupport securitySupport;
public CatalogController(CatalogService catalogService) {
public CatalogController(CatalogService catalogService, SecuritySupport securitySupport) {
this.catalogService = catalogService;
this.securitySupport = securitySupport;
}
@GetMapping("/catalogs/summary")
@@ -28,47 +31,47 @@ public class CatalogController {
@GetMapping("/admin")
public CatalogService.AdministrationOverview administrationOverview() {
return catalogService.administrationOverview();
return catalogService.administrationOverview(securitySupport.currentUser().id());
}
@PostMapping("/admin/farmers")
public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) {
return catalogService.saveFarmers(mutations);
return catalogService.saveFarmers(securitySupport.currentUser().id(), mutations);
}
@PostMapping("/admin/medications")
public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) {
return catalogService.saveMedications(mutations);
return catalogService.saveMedications(securitySupport.currentUser().id(), mutations);
}
@PostMapping("/admin/pathogens")
public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) {
return catalogService.savePathogens(mutations);
return catalogService.savePathogens(securitySupport.currentUser().id(), mutations);
}
@PostMapping("/admin/antibiotics")
public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
return catalogService.saveAntibiotics(mutations);
return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations);
}
@GetMapping("/portal/users")
public List<CatalogService.UserRow> users() {
return catalogService.listUsers();
return catalogService.listUsers(securitySupport.currentUser().id());
}
@PostMapping("/portal/users")
public CatalogService.UserRow saveUser(@RequestBody CatalogService.UserMutation mutation) {
return catalogService.createOrUpdateUser(mutation);
return catalogService.createOrUpdateUser(securitySupport.currentUser().id(), mutation);
}
@DeleteMapping("/portal/users/{id}")
public void deleteUser(@PathVariable String id) {
catalogService.deleteUser(id);
catalogService.deleteUser(securitySupport.currentUser().id(), id);
}
@PostMapping("/portal/users/{id}/password")
public void changePassword(@PathVariable String id, @RequestBody PasswordChangeRequest request) {
catalogService.changePassword(id, request.password());
catalogService.changePassword(securitySupport.currentUser().id(), id, request.password());
}
public record PasswordChangeRequest(String password) {

View File

@@ -3,6 +3,7 @@ package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.PortalService;
import de.svencarstensen.muh.service.ReportService;
import de.svencarstensen.muh.service.SampleService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -26,10 +27,12 @@ public class PortalController {
private final PortalService portalService;
private final ReportService reportService;
private final SecuritySupport securitySupport;
public PortalController(PortalService portalService, ReportService reportService) {
public PortalController(PortalService portalService, ReportService reportService, SecuritySupport securitySupport) {
this.portalService = portalService;
this.reportService = reportService;
this.securitySupport = securitySupport;
}
@GetMapping("/snapshot")
@@ -40,27 +43,41 @@ public class PortalController {
@RequestParam(required = false) Long sampleNumber,
@RequestParam(required = false) LocalDate date
) {
return portalService.snapshot(farmerBusinessKey, farmerQuery, cowQuery, sampleNumber, date);
var currentUser = securitySupport.currentUser();
return portalService.snapshot(
currentUser.role() == de.svencarstensen.muh.domain.UserRole.ADMIN,
currentUser.id(),
farmerBusinessKey,
farmerQuery,
cowQuery,
sampleNumber,
date
);
}
@GetMapping("/reports")
public List<ReportService.ReportCandidate> reports() {
return reportService.reportCandidates();
return reportService.reportCandidates(securitySupport.currentUser().id());
}
@GetMapping("/search/by-date")
public List<PortalService.PortalSampleRow> searchByDate(@RequestParam LocalDate date) {
return portalService.searchSamplesByCreatedDate(securitySupport.currentUser().id(), date);
}
@PostMapping("/reports/send")
public ReportService.DispatchResult send(@RequestBody ReportDispatchRequest request) {
return reportService.sendReports(request.sampleIds());
return reportService.sendReports(securitySupport.currentUser().id(), request.sampleIds());
}
@PatchMapping("/reports/{sampleId}/block")
public SampleService.SampleDetail block(@PathVariable String sampleId, @RequestBody BlockRequest request) {
return reportService.toggleReportBlocked(sampleId, request.blocked());
return reportService.toggleReportBlocked(securitySupport.currentUser().id(), sampleId, request.blocked());
}
@GetMapping("/reports/{sampleId}/pdf")
public ResponseEntity<byte[]> pdf(@PathVariable String sampleId) {
byte[] pdf = reportService.reportPdf(sampleId);
byte[] pdf = reportService.reportPdf(securitySupport.currentUser().id(), sampleId);
return ResponseEntity.ok()
.contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF))
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()

View File

@@ -1,5 +1,7 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.security.AuthenticatedUser;
import de.svencarstensen.muh.security.SecuritySupport;
import de.svencarstensen.muh.service.SampleService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -14,53 +16,76 @@ import org.springframework.web.bind.annotation.RestController;
public class SampleController {
private final SampleService sampleService;
private final SecuritySupport securitySupport;
public SampleController(SampleService sampleService) {
public SampleController(SampleService sampleService, SecuritySupport securitySupport) {
this.sampleService = sampleService;
this.securitySupport = securitySupport;
}
@GetMapping("/dashboard")
public SampleService.DashboardOverview dashboardOverview() {
return sampleService.dashboardOverview();
return sampleService.dashboardOverview(securitySupport.currentUser().id());
}
@GetMapping("/dashboard/lookup/{sampleNumber}")
public SampleService.LookupResult lookup(@PathVariable long sampleNumber) {
return sampleService.lookup(sampleNumber);
return sampleService.lookup(securitySupport.currentUser().id(), sampleNumber);
}
@GetMapping("/samples/{id}")
public SampleService.SampleDetail sample(@PathVariable String id) {
return sampleService.getSample(id);
return sampleService.getSample(securitySupport.currentUser().id(), id);
}
@GetMapping("/samples/by-number/{sampleNumber}")
public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) {
return sampleService.getSampleByNumber(sampleNumber);
return sampleService.getSampleByNumber(securitySupport.currentUser().id(), sampleNumber);
}
@PostMapping("/samples")
public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) {
return sampleService.createSample(request);
AuthenticatedUser user = securitySupport.currentUser();
return sampleService.createSample(user.id(), new SampleService.RegistrationRequest(
request.farmerBusinessKey(),
request.cowNumber(),
request.cowName(),
request.sampleKind(),
request.samplingMode(),
request.flaggedQuarters(),
deriveUserLabel(user.displayName()),
user.displayName()
));
}
@PutMapping("/samples/{id}/registration")
public SampleService.SampleDetail saveRegistration(@PathVariable String id, @RequestBody SampleService.RegistrationRequest request) {
return sampleService.saveRegistration(id, request);
return sampleService.saveRegistration(securitySupport.currentUser().id(), id, request);
}
@PutMapping("/samples/{id}/anamnesis")
public SampleService.SampleDetail saveAnamnesis(@PathVariable String id, @RequestBody SampleService.AnamnesisRequest request) {
return sampleService.saveAnamnesis(id, request);
return sampleService.saveAnamnesis(securitySupport.currentUser().id(), id, request);
}
@PutMapping("/samples/{id}/antibiogram")
public SampleService.SampleDetail saveAntibiogram(@PathVariable String id, @RequestBody SampleService.AntibiogramRequest request) {
return sampleService.saveAntibiogram(id, request);
return sampleService.saveAntibiogram(securitySupport.currentUser().id(), id, request);
}
@PutMapping("/samples/{id}/therapy")
public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) {
return sampleService.saveTherapy(id, request);
return sampleService.saveTherapy(securitySupport.currentUser().id(), id, request);
}
private String deriveUserLabel(String displayName) {
if (displayName == null || displayName.isBlank()) {
return "USR";
}
String compact = displayName.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
if (compact.isBlank()) {
return "USR";
}
return compact.substring(0, Math.min(4, compact.length()));
}
}

View File

@@ -1,8 +1,12 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService;
import de.svencarstensen.muh.service.InvoiceTemplateService;
import de.svencarstensen.muh.service.ReportTemplateService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.web.bind.annotation.GetMapping;
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;
@@ -15,47 +19,93 @@ import java.util.List;
public class SessionController {
private final CatalogService catalogService;
private final InvoiceTemplateService invoiceTemplateService;
private final ReportTemplateService reportTemplateService;
private final SecuritySupport securitySupport;
public SessionController(CatalogService catalogService) {
public SessionController(
CatalogService catalogService,
InvoiceTemplateService invoiceTemplateService,
ReportTemplateService reportTemplateService,
SecuritySupport securitySupport
) {
this.catalogService = catalogService;
this.invoiceTemplateService = invoiceTemplateService;
this.reportTemplateService = reportTemplateService;
this.securitySupport = securitySupport;
}
@GetMapping("/users")
public List<CatalogService.UserOption> activeUsers() {
return catalogService.activeCatalogSummary().users();
@GetMapping("/me")
public CatalogService.UserOption currentUser() {
return catalogService.currentUser(securitySupport.currentUser().id());
}
@PostMapping("/login")
public CatalogService.UserOption login(@RequestBody LoginRequest request) {
return catalogService.loginByCode(request.code());
@GetMapping("/invoice-template")
public InvoiceTemplateService.InvoiceTemplateResponse currentInvoiceTemplate() {
return invoiceTemplateService.currentTemplate(securitySupport.currentUser().id());
}
@GetMapping("/report-template")
public InvoiceTemplateService.InvoiceTemplateResponse currentReportTemplate() {
return reportTemplateService.currentTemplate(securitySupport.currentUser().id());
}
@PostMapping("/password-login")
public CatalogService.UserOption passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.identifier(), request.password());
public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.email(), request.password());
}
@PostMapping("/register")
public CatalogService.UserOption register(@RequestBody RegistrationRequest request) {
public CatalogService.SessionResponse register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(),
request.address(),
request.street(),
request.houseNumber(),
request.postalCode(),
request.city(),
request.email(),
request.phoneNumber(),
request.password()
));
}
public record LoginRequest(@NotBlank String code) {
@PutMapping("/invoice-template")
public InvoiceTemplateService.InvoiceTemplateResponse saveInvoiceTemplate(
@RequestBody TemplateRequest request
) {
return invoiceTemplateService.saveTemplate(
securitySupport.currentUser().id(),
request.elements()
);
}
public record PasswordLoginRequest(@NotBlank String identifier, @NotBlank String password) {
@PutMapping("/report-template")
public InvoiceTemplateService.InvoiceTemplateResponse saveReportTemplate(
@RequestBody TemplateRequest request
) {
return reportTemplateService.saveTemplate(
securitySupport.currentUser().id(),
request.elements()
);
}
public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) {
}
public record RegistrationRequest(
@NotBlank String companyName,
@NotBlank String address,
@NotBlank String street,
@NotBlank String houseNumber,
@NotBlank String postalCode,
@NotBlank String city,
@NotBlank String email,
@NotBlank String phoneNumber,
@NotBlank String password
) {
}
public record TemplateRequest(
List<InvoiceTemplateService.InvoiceTemplateElementPayload> elements
) {
}
}

View File

@@ -0,0 +1,18 @@
package de.svencarstensen.muh.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SpaForwardController {
@GetMapping({
"/",
"/{path:^(?!api$)[^.]+}",
"/{path:^(?!api$)[^.]+}/{segment:[^.]+}",
"/{path:^(?!api$)[^.]+}/{segment:[^.]+}/{leaf:[^.]+}"
})
public String forward() {
return "forward:/index.html";
}
}

View File

@@ -0,0 +1,17 @@
package de.svencarstensen.muh.web.dto;
import java.util.List;
public record AdminStatistics(
long totalVets,
long totalSamples,
List<VetSampleStats> samplesPerVet
) {
public record VetSampleStats(
String userId,
String displayName,
String companyName,
long sampleCount
) {
}
}

View File

@@ -1,12 +1,18 @@
server:
port: 8090
error:
include-message: always
spring:
config:
import:
- optional:file:./.env[.properties]
- optional:file:../.env[.properties]
application:
name: muh-backend
data:
mongodb:
uri: mongodb://192.168.180.25:27017/muh
uri: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}
jackson:
time-zone: Europe/Berlin
mail:
@@ -14,8 +20,11 @@ spring:
port: ${MUH_MAIL_PORT:587}
username: ${MUH_MAIL_USERNAME:}
password: ${MUH_MAIL_PASSWORD:}
protocol: ${MUH_MAIL_PROTOCOL:smtp}
properties:
mail:
transport:
protocol: ${MUH_MAIL_PROTOCOL:smtp}
smtp:
auth: ${MUH_MAIL_AUTH:false}
starttls:
@@ -24,6 +33,13 @@ spring:
muh:
cors:
allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
security:
token-secret: ${MUH_TOKEN_SECRET:}
token-validity-hours: ${MUH_TOKEN_VALIDITY_HOURS:12}
mongodb:
url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}
username: ${MUH_MONGODB_USERNAME:}
password: ${MUH_MONGODB_PASSWORD:}
mail:
enabled: ${MUH_MAIL_ENABLED:false}
from: ${MUH_MAIL_FROM:no-reply@muh.local}

View File

@@ -0,0 +1,39 @@
Anbei finden Sie die Milchprobenauswertung Nr. {{sampleNumber}} fuer Ihre Kuh {{cowLabel}}.
Dies ist eine automatisch generierte E-Mail von einer System-E-Mail-Adresse, bitte nicht antworten.
Mit freundlichen Gruessen
{{customerSignature}}
Liste der Handelsnamen und Wirkstoffe
Laktation
Penicillin: Mastinject, Revozyn RTU, Penicillin Injektoren, Ingel Mamyzin
Amoxicillin Clavulansaeure: Synulox RTU/LC, Noroclav
Sulfadoxin Trimetoprim: Diatrim, Sulphix
Lincomycin Neomycin: Albiotic
Tylosin: Pharmasin
Cefquinom: Cobactan
Cefoperacon: Peracef
Enrofloxacin: Powerflox
Cefalexin/Kanamycin: Ubrolexin
Ampicillin/Cloxacillin: Gelstamp
Trockensteher
Framycetin: Benestermycin
Cefalonium: Cepravin
Penicillin: Mastinject, Revozyn RTU, Penicillin Injektoren, Naphpenzal T
Cloxacillin: Orbenin und Cloxacillin
Amoxicillin Clavulansaeure: Synulox RTU/LC
Sulfadoxin Trimetoprim: Diatrim
Lincomycin Neomycin: Albiotic
Tylosin: Pharmasin
Cefquinom: Cobactan
Cefoperacon: Peracef
Enrofloxacin: Powerflox

View File

@@ -8,7 +8,9 @@
"name": "muh-frontend",
"version": "0.0.1",
"dependencies": {
"chart.js": "^4.5.1",
"react": "18.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.2.0",
"react-router-dom": "6.23.1"
},
@@ -745,6 +747,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz",
@@ -1290,6 +1298,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1538,6 +1558,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",

View File

@@ -9,7 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"chart.js": "^4.5.1",
"react": "18.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.2.0",
"react-router-dom": "6.23.1"
},

View File

@@ -2,6 +2,7 @@ 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 AdminDashboardPage from "./pages/AdminDashboardPage";
import LoginPage from "./pages/LoginPage";
import SampleRegistrationPage from "./pages/SampleRegistrationPage";
import AnamnesisPage from "./pages/AnamnesisPage";
@@ -9,9 +10,21 @@ import AntibiogramPage from "./pages/AntibiogramPage";
import TherapyPage from "./pages/TherapyPage";
import AdministrationPage from "./pages/AdministrationPage";
import PortalPage from "./pages/PortalPage";
import SearchPage from "./pages/SearchPage";
import SearchFarmerPage from "./pages/SearchFarmerPage";
import SearchCalendarPage from "./pages/SearchCalendarPage";
import UserManagementPage from "./pages/UserManagementPage";
import ReportTemplatePage from "./pages/ReportTemplatePage";
import InvoiceTemplatePage from "./pages/InvoiceTemplatePage";
import InvoiceManagementPage from "./pages/InvoiceManagementPage";
function ProtectedRoutes() {
const { user } = useSession();
const { user, ready } = useSession();
const isAdmin = user?.role === "ADMIN";
if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>;
}
if (!user) {
return <Navigate to="/" replace />;
@@ -20,13 +33,26 @@ function ProtectedRoutes() {
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/home" element={<HomePage />} />
<Route path="/home" element={isAdmin ? <AdminDashboardPage /> : <HomePage />} />
<Route path="/admin/dashboard" element={<AdminDashboardPage />} />
<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="/report-template" element={<ReportTemplatePage />} />
<Route path="/admin" element={<Navigate to={isAdmin ? "/admin/landwirte" : "/admin/benutzer"} replace />} />
<Route path="/admin/benutzer" element={<UserManagementPage />} />
<Route path="/admin/landwirte" element={<AdministrationPage />} />
<Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} />
<Route path="/admin/antibiogramm" element={<AdministrationPage />} />
<Route path="/admin/rechnung/verwalten" element={<InvoiceManagementPage />} />
<Route path="/admin/rechnung/template" element={<InvoiceTemplatePage />} />
<Route path="/search" element={<Navigate to="/search/probe" replace />} />
<Route path="/search/probe" element={<SearchPage />} />
<Route path="/search/landwirt" element={<SearchFarmerPage />} />
<Route path="/search/kalendar" element={<SearchCalendarPage />} />
<Route path="/portal" element={<PortalPage />} />
</Route>
<Route path="*" element={<Navigate to="/home" replace />} />
@@ -35,7 +61,10 @@ function ProtectedRoutes() {
}
function ApplicationRouter() {
const { user } = useSession();
const { user, ready } = useSession();
if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>;
}
if (!user) {
return (
<Routes>

View File

@@ -0,0 +1,79 @@
import type { PortalSampleRow } from "../lib/types";
function formatDate(value: string | null) {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
type SampleSearchResultsSectionProps = {
eyebrow: string;
title: string;
emptyText: string;
samples: PortalSampleRow[];
onOpen: (sampleNumber: number) => void;
};
export default function SampleSearchResultsSection({
eyebrow,
title,
emptyText,
samples,
onOpen,
}: SampleSearchResultsSectionProps) {
return (
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">{eyebrow}</p>
<h3>{title}</h3>
</div>
</div>
{!samples.length ? (
<div className="empty-state">{emptyText}</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Probe</th>
<th>Erfasst</th>
<th>Landwirt</th>
<th>Kuh</th>
<th>Typ</th>
<th>Interne Bemerkung</th>
<th />
</tr>
</thead>
<tbody>
{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>
<button
type="button"
className="table-link"
onClick={() => onOpen(sample.sampleNumber)}
>
Oeffnen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -1,21 +1,17 @@
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",
"/admin/dashboard": "Admin Dashboard",
"/samples/new": "Neuanlage einer Probe",
"/admin": "Verwaltung",
"/portal": "MUH-Portal",
"/report-template": "Bericht",
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
"/admin/rechnung/template": "Rechnungsvorlage",
};
function resolvePageTitle(pathname: string) {
function resolvePageTitle(pathname: string, isAdmin: boolean) {
if (pathname.includes("/anamnesis")) {
return "Anamnese";
}
@@ -28,11 +24,14 @@ function resolvePageTitle(pathname: string) {
if (pathname.includes("/registration")) {
return "Probe bearbeiten";
}
if (pathname.startsWith("/admin/benutzer")) {
return isAdmin ? "Benutzerfreigabe" : "Verwaltung | Benutzer";
}
return PAGE_TITLES[pathname] ?? "MUH App";
}
export default function AppShell() {
const { user, setUser } = useSession();
const { user, setSession } = useSession();
const location = useLocation();
const navigate = useNavigate();
@@ -44,27 +43,106 @@ export default function AppShell() {
</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>
))}
{user?.role === "ADMIN" ? (
<>
<NavLink
to="/admin/dashboard"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Dashboard
</NavLink>
<div className="nav-group">
<div className="nav-group__label">Benutzerverwaltung</div>
<div className="nav-subnav">
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Freigabe / Sperre
</NavLink>
</div>
</div>
<div className="nav-group">
<div className="nav-group__label">Rechnung</div>
<div className="nav-subnav">
<NavLink to="/admin/rechnung/verwalten" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Verwalten
</NavLink>
<NavLink to="/admin/rechnung/template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Template
</NavLink>
</div>
</div>
</>
) : (
<>
<NavLink to="/home" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
Start
</NavLink>
<NavLink to="/samples/new" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
Neue Probe
</NavLink>
<div className="nav-group">
<div className="nav-group__label">Verwaltung</div>
<div className="nav-subnav">
<div className="nav-subgroup">
<div className="nav-subgroup__label">Vorlagen</div>
<div className="nav-subnav nav-subnav--nested">
<NavLink to="/report-template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Bericht
</NavLink>
</div>
</div>
<NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Landwirte
</NavLink>
<NavLink to="/admin/medikamente" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Medikamente
</NavLink>
<NavLink to="/admin/erreger" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Erreger
</NavLink>
<NavLink to="/admin/antibiogramm" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Antibiogramm
</NavLink>
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Benutzer
</NavLink>
</div>
</div>
<div className="nav-group">
<div className="nav-group__label">Suche</div>
<div className="nav-subnav">
<NavLink to="/search/landwirt" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Landwirt
</NavLink>
<NavLink to="/search/probe" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Probe
</NavLink>
<NavLink to="/search/kalendar" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Kalendar
</NavLink>
</div>
</div>
<NavLink to="/portal" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
Portal
</NavLink>
</>
)}
</nav>
<div className="sidebar__footer">
<div className="user-chip user-chip--stacked">
<span>{user?.displayName}</span>
<small>{user?.code}</small>
<small>{user?.email ?? user?.role}</small>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setUser(null);
setSession(null);
navigate("/");
}}
>
@@ -76,13 +154,7 @@ export default function AppShell() {
<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>
<h2>{resolvePageTitle(location.pathname, user?.role === "ADMIN")}</h2>
</div>
</header>

View File

@@ -1,25 +1,87 @@
const API_ROOT = import.meta.env.VITE_API_URL ?? "http://localhost:8090/api";
import { AUTH_TOKEN_STORAGE_KEY } from "./storage";
const API_ROOT = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? "http://localhost:8090/api" : "/api");
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
type ApiErrorPayload = {
message?: string;
error?: string;
detail?: string;
};
async function readErrorMessage(response: Response): Promise<string> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const payload = (await response.json()) as ApiErrorPayload;
const message = payload.message?.trim() || payload.detail?.trim();
if (message) {
return message;
}
if (payload.error?.trim()) {
return `Anfrage fehlgeschlagen: ${payload.error.trim()}`;
}
}
const text = (await response.text()).trim();
if (text) {
return text;
}
return `Anfrage fehlgeschlagen (${response.status})`;
}
function authHeaders(): Record<string, string> {
if (typeof window === "undefined") {
return {};
}
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!token) {
return {};
}
return { Authorization: `Bearer ${token}` };
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const text = await response.text();
throw new Error(text || "Unbekannter API-Fehler");
throw new ApiError(await readErrorMessage(response), response.status);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
const text = await response.text();
if (!text.trim()) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export async function apiGet<T>(path: string): Promise<T> {
return handleResponse<T>(await fetch(`${API_ROOT}${path}`));
return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, {
headers: authHeaders(),
}),
);
}
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" },
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body),
}),
);
@@ -29,7 +91,10 @@ 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" },
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body),
}),
);
@@ -39,7 +104,10 @@ 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" },
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body),
}),
);
@@ -49,6 +117,7 @@ export async function apiDelete(path: string): Promise<void> {
await handleResponse<void>(
await fetch(`${API_ROOT}${path}`, {
method: "DELETE",
headers: authHeaders(),
}),
);
}

View File

@@ -6,17 +6,20 @@ import {
useState,
type PropsWithChildren,
} from "react";
import { USER_STORAGE_KEY } from "./storage";
import type { UserOption } from "./types";
import { apiGet } from "./api";
import { AUTH_TOKEN_STORAGE_KEY, USER_STORAGE_KEY } from "./storage";
import type { SessionResponse, UserOption } from "./types";
interface SessionContextValue {
user: UserOption | null;
setUser: (user: UserOption | null) => void;
ready: boolean;
setSession: (session: SessionResponse | null) => void;
}
const SessionContext = createContext<SessionContextValue>({
user: null,
setUser: () => undefined,
ready: false,
setSession: () => undefined,
});
function loadStoredUser(): UserOption | null {
@@ -33,6 +36,39 @@ function loadStoredUser(): UserOption | null {
export function SessionProvider({ children }: PropsWithChildren) {
const [user, setUserState] = useState<UserOption | null>(() => loadStoredUser());
const [ready, setReady] = useState(false);
useEffect(() => {
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!token) {
setReady(true);
return;
}
let cancelled = false;
void apiGet<UserOption>("/session/me")
.then((currentUser) => {
if (!cancelled) {
setUserState(currentUser);
}
})
.catch(() => {
if (!cancelled) {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(USER_STORAGE_KEY);
setUserState(null);
}
})
.finally(() => {
if (!cancelled) {
setReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (user) {
@@ -42,12 +78,26 @@ export function SessionProvider({ children }: PropsWithChildren) {
window.localStorage.removeItem(USER_STORAGE_KEY);
}, [user]);
function setSession(session: SessionResponse | null) {
if (session) {
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, session.token);
setUserState(session.user);
setReady(true);
return;
}
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(USER_STORAGE_KEY);
setUserState(null);
setReady(true);
}
const value = useMemo(
() => ({
user,
setUser: setUserState,
ready,
setSession,
}),
[user],
[ready, user],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;

View File

@@ -1 +1,2 @@
export const USER_STORAGE_KEY = "muh.current-user";
export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token";

View File

@@ -16,7 +16,7 @@ export type MedicationCategory =
| "SYSTEMIC_PAIN"
| "DRY_SEALER"
| "DRY_ANTIBIOTIC";
export type UserRole = "APP" | "ADMIN" | "CUSTOMER";
export type UserRole = "ADMIN" | "CUSTOMER";
export interface FarmerOption {
businessKey: string;
@@ -45,15 +45,24 @@ export interface AntibioticOption {
export interface UserOption {
id: string;
code: string;
primaryUser: boolean;
displayName: string;
companyName: string | null;
address: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
portalLogin: string | null;
phoneNumber: string | null;
role: UserRole;
}
export interface SessionResponse {
token: string;
user: UserOption;
}
export interface UserRow extends UserOption {
active: boolean;
updatedAt: string;

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { apiGet } from "../lib/api";
// Chart.js Komponenten registrieren
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
interface VetSampleStats {
userId: string;
displayName: string;
companyName: string | null;
sampleCount: number;
}
interface AdminStatistics {
totalVets: number;
totalSamples: number;
samplesPerVet: VetSampleStats[];
}
export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadStats() {
try {
const response = await apiGet<AdminStatistics>("/admin/statistics");
setStats(response);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
void loadStats();
}, []);
// Chart Daten vorbereiten
const chartData = {
labels: stats?.samplesPerVet.map((vet) => vet.displayName) || [],
datasets: [
{
label: "Anzahl Proben",
data: stats?.samplesPerVet.map((vet) => vet.sampleCount) || [],
backgroundColor: "rgba(90, 123, 168, 0.8)",
borderColor: "rgba(90, 123, 168, 1)",
borderWidth: 1,
borderRadius: 8,
borderSkipped: false,
},
],
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
title: {
display: true,
text: "Proben pro Tierarzt",
font: {
size: 16,
weight: "bold" as const,
},
padding: {
top: 10,
bottom: 20,
},
color: "#1d2428",
},
tooltip: {
backgroundColor: "rgba(29, 36, 40, 0.9)",
padding: 12,
cornerRadius: 8,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
callbacks: {
label: (context: { parsed: { y: number } }) => {
return `${context.parsed.y} Proben`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: "#666",
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
title: {
display: true,
text: "Anzahl Proben",
color: "#666",
font: {
size: 12,
},
},
},
x: {
ticks: {
color: "#666",
maxRotation: 45,
minRotation: 45,
},
grid: {
display: false,
},
title: {
display: true,
text: "Tierarzt",
color: "#666",
font: {
size: 12,
},
},
},
},
};
return (
<div className="page-stack">
{/* Header Bereich */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Administration</p>
<h3>Administrator Dashboard</h3>
<p className="muted-text">
Übersicht über Tierärzte und Proben im System.
</p>
</div>
</section>
{error ? (
<div className="alert alert--error">{error}</div>
) : null}
{/* Statistik-Karten */}
<section className="metrics-grid admin-metrics">
<article className="metric-card metric-card--primary">
<span className="metric-card__label">Tierärzte</span>
<strong className="metric-card__value--large">
{loading ? "..." : stats?.totalVets ?? 0}
</strong>
</article>
<article className="metric-card metric-card--secondary">
<span className="metric-card__label">Proben insgesamt</span>
<strong className="metric-card__value--large">
{loading ? "..." : stats?.totalSamples ?? 0}
</strong>
</article>
</section>
{/* Chart Bereich */}
<section className="section-card">
<div className="chart-container">
{loading ? (
<div className="empty-state">Chart wird geladen...</div>
) : stats?.samplesPerVet.length === 0 ? (
<div className="empty-state">Noch keine Proben vorhanden.</div>
) : (
<Bar data={chartData} options={chartOptions} />
)}
</div>
</section>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { apiGet, apiPost } from "../lib/api";
import type { AdministrationOverview, MedicationCategory, PathogenKind } from "../lib/types";
@@ -25,6 +26,13 @@ const DATASET_LABELS: Record<DatasetKey, string> = {
antibiotics: "Antibiogramm",
};
const DATASET_TITLES: Record<DatasetKey, string> = {
farmers: "Die Verwaltung der Landwirte",
medications: "Die Verwaltung der Medikamente",
pathogens: "Die Verwaltung der Erreger",
antibiotics: "Die Verwaltung der Antibiogramme",
};
function normalizeOverview(overview: AdministrationOverview): DatasetsState {
return {
farmers: overview.farmers.map((entry) => ({
@@ -92,10 +100,24 @@ function emptyRow(dataset: DatasetKey): EditableRow {
}
export default function AdministrationPage() {
const location = useLocation();
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);
const [showValidation, setShowValidation] = useState(false);
const selectedDataset: DatasetKey = useMemo(() => {
if (location.pathname.startsWith("/admin/medikamente")) {
return "medications";
}
if (location.pathname.startsWith("/admin/erreger")) {
return "pathogens";
}
if (location.pathname.startsWith("/admin/antibiogramm")) {
return "antibiotics";
}
return "farmers";
}, [location.pathname]);
useEffect(() => {
async function load() {
@@ -143,6 +165,11 @@ export default function AdministrationPage() {
if (!datasets) {
return;
}
setShowValidation(true);
if (rows.some((row) => !row.name.trim())) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
setSaving(true);
setMessage(null);
try {
@@ -196,7 +223,7 @@ export default function AdministrationPage() {
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Stammdaten direkt pflegen</h3>
<h3>{DATASET_TITLES[selectedDataset]}</h3>
<p className="muted-text">
Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte
Satz inaktiv sichtbar.
@@ -216,26 +243,13 @@ export default function AdministrationPage() {
<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>
<th className="required-label">Name</th>
{selectedDataset === "farmers" ? <th>E-Mail</th> : null}
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
@@ -248,6 +262,7 @@ export default function AdministrationPage() {
<tr key={`${row.id || "new"}-${index}`}>
<td>
<input
className={showValidation && !row.name.trim() ? "is-invalid" : ""}
value={row.name}
onChange={(event) => updateRow(index, { name: event.target.value })}
/>
@@ -272,7 +287,7 @@ export default function AdministrationPage() {
<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>
<option value="DRY_ANTIBIOTIC">Trockenstellerprobe Antibiotika</option>
</select>
</td>
) : null}

View File

@@ -30,6 +30,7 @@ export default function AnamnesisPage() {
const [activeQuarter, setActiveQuarter] = useState<QuarterKey | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [showValidation, setShowValidation] = useState(false);
useEffect(() => {
async function load() {
@@ -70,10 +71,22 @@ export default function AnamnesisPage() {
}));
}
function quarterHasPathogen(quarterKey: QuarterKey) {
const quarterState = quarterStates[quarterKey];
return Boolean(quarterState?.pathogenBusinessKey || quarterState?.customPathogenName?.trim());
}
async function handleSave() {
if (!sampleId || !sample) {
return;
}
setShowValidation(true);
const missingQuarter = sample.quarters.find((quarter) => !quarterHasPathogen(quarter.quarterKey));
if (missingQuarter) {
setActiveQuarter(missingQuarter.quarterKey);
setMessage("Bitte fuer jede Entnahmestelle einen Erreger auswaehlen oder eingeben.");
return;
}
setSaving(true);
setMessage(null);
@@ -134,11 +147,13 @@ export default function AnamnesisPage() {
<button
key={quarter.quarterKey}
type="button"
className={`tab-chip ${activeQuarter === quarter.quarterKey ? "is-active" : ""}`}
className={`tab-chip ${activeQuarter === quarter.quarterKey ? "is-active" : ""} ${
showValidation && !quarterHasPathogen(quarter.quarterKey) ? "is-invalid" : ""
}`}
onClick={() => setActiveQuarter(quarter.quarterKey)}
>
{quarter.label}
{quarter.flagged ? " " : ""}
{quarter.flagged ? " Auffällig" : ""}
</button>
))}
</div>
@@ -153,7 +168,8 @@ export default function AnamnesisPage() {
<div className="info-chip">Auffaelliges Viertel markiert</div>
) : null}
<div className="pathogen-grid">
<p className="required-label">Erreger</p>
<div className={`pathogen-grid ${showValidation && !quarterHasPathogen(visibleQuarter.quarterKey) ? "is-invalid" : ""}`}>
{catalogs.pathogens.map((pathogen) => (
<button
key={pathogen.businessKey}
@@ -170,14 +186,14 @@ export default function AnamnesisPage() {
disabled={!sample.anamnesisEditable}
>
<strong>{pathogen.name}</strong>
<small>{pathogen.code ?? pathogen.kind}</small>
</button>
))}
</div>
<label className="field">
<label className="field field--required field--spaced">
<span>Erreger manuell eingeben</span>
<input
className={showValidation && !quarterHasPathogen(visibleQuarter.quarterKey) ? "is-invalid" : ""}
value={state.customPathogenName}
onChange={(event) =>
updateQuarter(visibleQuarter.quarterKey, {
@@ -202,10 +218,10 @@ export default function AnamnesisPage() {
/>
</label>
<div className="info-panel">
<div className="info-panel info-panel--spaced">
<strong>Hinweis</strong>
<p>
Kein Wachstum oder verunreinigte Proben werden spaeter automatisch vom
Kein Wachstum oder verunreinigte Proben werden später automatisch vom
Antibiogramm ausgeschlossen.
</p>
</div>

View File

@@ -27,6 +27,7 @@ export default function HomePage() {
const [sampleNumber, setSampleNumber] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [showValidation, setShowValidation] = useState(false);
useEffect(() => {
async function loadDashboard() {
@@ -43,6 +44,7 @@ export default function HomePage() {
async function handleLookup(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!sampleNumber.trim()) {
setMessage("Bitte eine Probennummer eingeben.");
return;
@@ -72,21 +74,19 @@ export default function HomePage() {
</p>
</div>
<form className="hero-card__form" onSubmit={handleLookup}>
<label className="field">
<form className={`hero-card__form ${showValidation ? "show-validation" : ""}`} onSubmit={handleLookup}>
<label className="field field--required">
<span>Nummer</span>
<input
value={sampleNumber}
onChange={(event) => setSampleNumber(event.target.value)}
placeholder="z. B. 100203"
inputMode="numeric"
required
/>
</label>
<button type="submit" className="accent-button">
Probe oeffnen
</button>
<button type="button" className="secondary-button" onClick={() => navigate("/samples/new")}>
Neuanlage einer Probe
Probe öffnen
</button>
</form>
@@ -162,7 +162,7 @@ export default function HomePage() {
)
}
>
Oeffnen
Öffnen
</button>
</td>
</tr>

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import { apiGet } from "../lib/api";
interface InvoiceSummary {
id: string;
invoiceNumber: string;
customerName: string;
invoiceDate: string;
dueDate: string;
totalAmount: number;
status: "DRAFT" | "SENT" | "PAID" | "OVERDUE" | "CANCELLED";
}
interface InvoiceOverview {
invoices: InvoiceSummary[];
}
const STATUS_LABELS: Record<InvoiceSummary["status"], string> = {
DRAFT: "Entwurf",
SENT: "Versendet",
PAID: "Bezahlt",
OVERDUE: "Überfällig",
CANCELLED: "Storniert",
};
const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
DRAFT: "status-badge--draft",
SENT: "status-badge--sent",
PAID: "status-badge--success",
OVERDUE: "status-badge--error",
CANCELLED: "status-badge--neutral",
};
export default function InvoiceManagementPage() {
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
apiGet<InvoiceOverview>("/admin/invoices")
.then((response) => {
if (!cancelled) {
setInvoices(response.invoices);
}
})
.catch((err) => {
if (!cancelled) {
// Für den Moment zeigen wir einfach eine leere Liste an
// bis das Backend implementiert ist
setInvoices([]);
setError(null);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
}).format(new Date(dateString));
};
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Rechnungsverwaltung</p>
<h3>Übersicht aller Rechnungen</h3>
<p className="muted-text">
Hier können Sie alle erstellten Rechnungen einsehen, deren Status verfolgen
und geplante Rechnungen verwalten.
</p>
</div>
{error ? <div className="alert alert--error">{error}</div> : null}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Rechnungen</p>
<h3>Rechnungsliste</h3>
</div>
<div className="page-actions">
<button type="button" className="accent-button" disabled>
Neue Rechnung
</button>
</div>
</div>
{loading ? (
<div className="empty-state">Rechnungen werden geladen...</div>
) : invoices.length === 0 ? (
<div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text">
Die Rechnungsverwaltung wird in Kürze verfügbar sein.
</p>
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Rechnungsnr.</th>
<th>Kunde</th>
<th>Rechnungsdatum</th>
<th>Fällig am</th>
<th>Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td>{invoice.invoiceNumber}</td>
<td>{invoice.customerName}</td>
<td>{formatDate(invoice.invoiceDate)}</td>
<td>{formatDate(invoice.dueDate)}</td>
<td>{formatCurrency(invoice.totalAmount)}</td>
<td>
<span className={`status-badge ${STATUS_CLASSES[invoice.status]}`}>
{STATUS_LABELS[invoice.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Zusammenfassung</p>
<h3>Statistik</h3>
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-card__value">{invoices.length}</span>
<span className="stat-card__label">Gesamtrechnungen</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "PAID").length}
</span>
<span className="stat-card__label">Bezahlt</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "OVERDUE").length}
</span>
<span className="stat-card__label">Überfällig</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{formatCurrency(
invoices
.filter((i) => i.status === "PAID")
.reduce((sum, i) => sum + i.totalAmount, 0)
)}
</span>
<span className="stat-card__label">Gesamtumsatz</span>
</div>
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { FormEvent, useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
import type { SessionResponse } from "../lib/types";
type FeedbackState =
| { type: "error"; text: string }
@@ -9,59 +10,50 @@ type FeedbackState =
| null;
export default function LoginPage() {
const [users, setUsers] = useState<UserOption[]>([]);
const [manualCode, setManualCode] = useState("");
const [identifier, setIdentifier] = useState("");
const [showRegistration, setShowRegistration] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loginInputsUnlocked, setLoginInputsUnlocked] = useState(false);
const [showLoginValidation, setShowLoginValidation] = useState(false);
const [showRegisterValidation, setShowRegisterValidation] = useState(false);
const [registration, setRegistration] = useState({
companyName: "",
address: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
password: "",
passwordConfirmation: "",
});
const [loading, setLoading] = useState(true);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setUser } = useSession();
const { setSession } = useSession();
const navigate = useNavigate();
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 });
}
function unlockLoginInputs() {
setLoginInputsUnlocked(true);
}
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowLoginValidation(true);
if (!email.trim() || !password.trim()) {
setFeedback({
type: "error",
text: "Bitte E-Mail und Passwort eingeben.",
});
return;
}
try {
const response = await apiPost<UserOption>("/session/password-login", {
identifier,
setFeedback(null);
const response = await apiPost<SessionResponse>("/session/password-login", {
email: email.trim(),
password,
});
setUser(response);
setSession(response);
// Admin zum Dashboard, Kunden zur Startseite
navigate(response.user.role === "ADMIN" ? "/admin/dashboard" : "/home");
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
@@ -69,13 +61,39 @@ export default function LoginPage() {
async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowRegisterValidation(true);
if (
!registration.companyName.trim()
|| !registration.street.trim()
|| !registration.houseNumber.trim()
|| !registration.postalCode.trim()
|| !registration.city.trim()
|| !registration.email.trim()
|| !registration.phoneNumber.trim()
|| !registration.password.trim()
) {
setFeedback({
type: "error",
text: "Bitte alle Pflichtfelder inklusive Telefonnummer fuer die Registrierung ausfuellen.",
});
return;
}
if (registration.password !== registration.passwordConfirmation) {
setFeedback({ type: "error", text: "Die beiden Passwoerter stimmen nicht ueberein." });
return;
}
try {
const response = await apiPost<UserOption>("/session/register", registration);
setFeedback(null);
const { passwordConfirmation, ...registrationPayload } = registration;
void passwordConfirmation;
const response = await apiPost<SessionResponse>("/session/register", registrationPayload);
setFeedback({
type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`,
text: `Registrierung erfolgreich. Willkommen ${response.user.companyName ?? response.user.displayName}.`,
});
setUser(response);
setSession(response);
// Admin zum Dashboard, Kunden zur Startseite
navigate(response.user.role === "ADMIN" ? "/admin/dashboard" : "/home");
} catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message });
}
@@ -88,7 +106,7 @@ export default function LoginPage() {
<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
Fokus auf klare Arbeitsabläufe, schnelle Probenbearbeitung und ein Portal
fuer Verwaltung, Berichtsdruck und Versandstatus.
</p>
</div>
@@ -98,8 +116,7 @@ export default function LoginPage() {
<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.
Anmeldung per E-Mail mit Passwort sowie direkte Kundenregistrierung.
</p>
{feedback ? (
@@ -108,146 +125,194 @@ export default function LoginPage() {
</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>
<div className="auth-grid">
{!showRegistration ? (
<form
className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`}
onSubmit={handlePasswordLogin}
autoComplete="off"
>
<label className="field field--required">
<span>E-Mail</span>
<input
value={manualCode}
onChange={(event) => setManualCode(event.target.value.toUpperCase())}
placeholder="z. B. SV"
type="email"
name="login-email"
value={email}
onChange={(event) => setEmail(event.target.value)}
onFocus={unlockLoginInputs}
onPointerDown={unlockLoginInputs}
placeholder="z. B. name@hof.de"
autoComplete="off"
readOnly={!loginInputsUnlocked}
required
/>
</label>
<label className="field field--required">
<span>Passwort</span>
<input
type="password"
name="login-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onFocus={unlockLoginInputs}
onPointerDown={unlockLoginInputs}
autoComplete="new-password"
readOnly={!loginInputsUnlocked}
required
/>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Mit Passwort anmelden
</button>
<button
type="button"
className="accent-button"
onClick={() => void handleCodeLogin(manualCode)}
className="secondary-button"
onClick={() => {
setFeedback(null);
setShowRegisterValidation(false);
setShowRegistration(true);
}}
>
Mit Kuerzel anmelden
Registrieren
</button>
</div>
</div>
</form>
) : (
<form className={`login-panel__section ${showRegisterValidation ? "show-validation" : ""}`} onSubmit={handleRegister}>
<div className="field-grid">
<label className="field field--wide field--required">
<span>Firmenname</span>
<input
value={registration.companyName}
onChange={(event) =>
setRegistration((current) => ({ ...current, companyName: event.target.value }))
}
placeholder="z. B. Muster Agrar GmbH"
required
/>
</label>
<label className="field field--required">
<span>Strasse</span>
<input
value={registration.street}
onChange={(event) =>
setRegistration((current) => ({ ...current, street: event.target.value }))
}
placeholder="z. B. Dorfstrasse"
required
/>
</label>
<label className="field field--required">
<span>Hausnummer</span>
<input
value={registration.houseNumber}
onChange={(event) =>
setRegistration((current) => ({ ...current, houseNumber: event.target.value }))
}
placeholder="z. B. 12a"
required
/>
</label>
<label className="field field--required">
<span>PLZ</span>
<input
value={registration.postalCode}
onChange={(event) =>
setRegistration((current) => ({ ...current, postalCode: event.target.value }))
}
placeholder="z. B. 12345"
required
/>
</label>
<label className="field field--required">
<span>Ort</span>
<input
value={registration.city}
onChange={(event) =>
setRegistration((current) => ({ ...current, city: event.target.value }))
}
placeholder="z. B. Musterstadt"
required
/>
</label>
<label className="field field--required">
<span>E-Mail</span>
<input
type="email"
value={registration.email}
onChange={(event) =>
setRegistration((current) => ({ ...current, email: event.target.value }))
}
required
/>
</label>
<label className="field field--required">
<span>Telefonnummer</span>
<input
type="tel"
value={registration.phoneNumber}
onChange={(event) =>
setRegistration((current) => ({ ...current, phoneNumber: event.target.value }))
}
placeholder="z. B. 04531 181424"
required
/>
</label>
<label className="field field--wide field--required">
<span>Passwort</span>
<input
type="password"
value={registration.password}
onChange={(event) =>
setRegistration((current) => ({ ...current, password: event.target.value }))
}
required
/>
</label>
<label className="field field--wide field--required">
<span>Passwort wiederholen</span>
<input
type="password"
value={registration.passwordConfirmation}
className={
showRegisterValidation
&& registration.password !== registration.passwordConfirmation
? "is-invalid"
: ""
}
aria-invalid={
showRegisterValidation && registration.password !== registration.passwordConfirmation
}
onChange={(event) =>
setRegistration((current) => ({
...current,
passwordConfirmation: event.target.value,
}))
}
required
/>
</label>
</div>
<div className="page-actions">
<button type="submit" className="accent-button">
Registrieren
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
setFeedback(null);
setShowLoginValidation(false);
setShowRegistration(false);
}}
>
Zurück
</button>
</div>
</form>
)}
</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

@@ -1,55 +1,25 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api";
import { apiDelete, apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
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 { user } = useSession();
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,
role: "CUSTOMER" as UserRole,
});
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
const [showUserValidation, setShowUserValidation] = useState(false);
const isAdmin = user?.role === "ADMIN";
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()}`);
const response = await apiGet<PortalSnapshot>("/portal/snapshot");
setSnapshot(response);
setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId));
}
@@ -69,16 +39,6 @@ export default function PortalPage() {
);
}
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", {
@@ -97,19 +57,23 @@ export default function PortalPage() {
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowUserValidation(true);
if (!isAdmin) {
setMessage("Nur Administratoren koennen Benutzer anlegen.");
return;
}
try {
await apiPost("/portal/users", {
...userForm,
active: true,
});
setUserForm({
code: "",
displayName: "",
email: "",
portalLogin: "",
password: "",
role: "APP",
role: "CUSTOMER",
});
setShowUserValidation(false);
setMessage("Benutzer gespeichert.");
await loadSnapshot();
} catch (userError) {
@@ -139,15 +103,6 @@ export default function PortalPage() {
}
}
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>;
}
@@ -203,227 +158,97 @@ export default function PortalPage() {
</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>
{isAdmin ? (
<article className="section-card">
<p className="eyebrow">Benutzerverwaltung</p>
<form className={`field-grid ${showUserValidation ? "show-validation" : ""}`} onSubmit={handleCreateUser}>
<label className="field field--required">
<span>Name</span>
<input
value={userForm.displayName}
onChange={(event) =>
setUserForm((current) => ({ ...current, displayName: event.target.value }))
}
required
/>
</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="CUSTOMER">CUSTOMER</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>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Passwort</th>
<th />
</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"}
</thead>
<tbody>
{snapshot.users.map((user) => (
<tr key={user.id}>
<td>{user.displayName}</td>
<td>{user.email ?? "-"}</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>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
) : null}
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ export default function SampleRegistrationPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
useEffect(() => {
async function load() {
@@ -79,6 +80,7 @@ export default function SampleRegistrationPage() {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!user) {
return;
}
@@ -97,7 +99,7 @@ export default function SampleRegistrationPage() {
sampleKind,
samplingMode,
flaggedQuarters,
userCode: user.code,
userCode: user.displayName,
userDisplayName: user.displayName,
};
@@ -118,14 +120,14 @@ export default function SampleRegistrationPage() {
}
return (
<form className="page-stack" onSubmit={handleSubmit}>
<form className={`page-stack ${showValidation ? "show-validation" : ""}`} 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.
Schalter Trockenstellerprobe markieren.
</p>
</div>
@@ -138,16 +140,17 @@ export default function SampleRegistrationPage() {
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="form-grid">
<section className="form-grid form-grid--stacked">
<article className="section-card">
<p className="eyebrow">Stammdaten</p>
<div className="field-grid">
<label className="field">
<div className="field-grid field-grid--stacked">
<label className="field field--required">
<span>Landwirt</span>
<select
value={farmerBusinessKey}
onChange={(event) => setFarmerBusinessKey(event.target.value)}
disabled={!editable}
required
>
{catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}>
@@ -157,12 +160,13 @@ export default function SampleRegistrationPage() {
</select>
</label>
<label className="field">
<label className="field field--required">
<span>Kuh-Nummer</span>
<input
value={cowNumber}
onChange={(event) => setCowNumber(event.target.value)}
disabled={!editable}
required
/>
</label>
@@ -194,7 +198,7 @@ export default function SampleRegistrationPage() {
onClick={() => setSampleKind("DRY_OFF")}
disabled={!editable}
>
TS
Trockenstellerprobe
</button>
</div>
@@ -247,7 +251,7 @@ export default function SampleRegistrationPage() {
disabled={!editable}
>
<span>{quarter.label}</span>
<strong>{flaggedQuarters.includes(quarter.key) ? "" : "OK"}</strong>
<strong>{flaggedQuarters.includes(quarter.key) ? "Auffällig" : "OK"}</strong>
</button>
))}
</div>

View File

@@ -0,0 +1,90 @@
import { useNavigate } from "react-router-dom";
import SampleSearchResultsSection from "../components/SampleSearchResultsSection";
import { apiGet } from "../lib/api";
import type { LookupResult, PortalSampleRow } from "../lib/types";
import { useState } from "react";
function routeForLookup(result: LookupResult) {
return result.sampleId && result.routeSegment ? `/samples/${result.sampleId}/${result.routeSegment}` : null;
}
export default function SearchCalendarPage() {
const navigate = useNavigate();
const [selectedDate, setSelectedDate] = useState("");
const [samples, setSamples] = useState<PortalSampleRow[]>([]);
const [message, setMessage] = useState<string | null>(null);
const [resultLabel, setResultLabel] = useState("Bitte Datum auswaehlen");
async function handleDateChange(nextDate: string) {
setSelectedDate(nextDate);
if (!nextDate) {
setSamples([]);
setMessage(null);
setResultLabel("Bitte Datum auswaehlen");
return;
}
try {
const rows = await apiGet<PortalSampleRow[]>(
`/portal/search/by-date?date=${encodeURIComponent(nextDate)}`,
);
setSamples(rows);
setResultLabel(`Erfasste Proben am ${nextDate.split("-").reverse().join(".")}`);
setMessage(rows.length ? null : "An diesem Tag wurden keine Proben erfasst.");
} catch (dateError) {
setSamples([]);
setResultLabel("Keine Treffer");
setMessage((dateError as Error).message);
}
}
async function openSample(sampleNumber: number) {
try {
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber}`);
const target = routeForLookup(result);
if (!result.found || !target) {
setMessage(result.message);
return;
}
navigate(target);
} catch (openError) {
setMessage((openError as Error).message);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Suche | Kalendar</p>
<h3>Proben nach Erfassungsdatum finden</h3>
<p className="muted-text">
Waehle einen Tag im Kalendar aus, um alle an diesem Datum erfassten Proben in der Liste zu sehen.
</p>
</div>
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="section-card">
<label className="field field--required">
<span>Kalendar</span>
<input
type="date"
value={selectedDate}
onChange={(event) => void handleDateChange(event.target.value)}
required
/>
</label>
</section>
<SampleSearchResultsSection
eyebrow="Suchergebnisse"
title={resultLabel}
emptyText="Bitte ein Datum im Kalendar auswaehlen."
samples={samples}
onOpen={(sampleNumber) => void openSample(sampleNumber)}
/>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import SampleSearchResultsSection from "../components/SampleSearchResultsSection";
import { apiGet } from "../lib/api";
import type { FarmerOption, LookupResult, PortalSampleRow, PortalSnapshot } from "../lib/types";
function routeForLookup(result: LookupResult) {
return result.sampleId && result.routeSegment ? `/samples/${result.sampleId}/${result.routeSegment}` : null;
}
export default function SearchFarmerPage() {
const navigate = useNavigate();
const [farmerQuery, setFarmerQuery] = useState("");
const [farmers, setFarmers] = useState<FarmerOption[]>([]);
const [samples, setSamples] = useState<PortalSampleRow[]>([]);
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const [resultLabel, setResultLabel] = useState("Bitte Landwirt suchen");
async function loadFarmerSamples(farmer: FarmerOption) {
const response = await apiGet<PortalSnapshot>(
`/portal/snapshot?farmerBusinessKey=${encodeURIComponent(farmer.businessKey)}`,
);
setSamples(response.samples);
setResultLabel(`Proben von ${farmer.name}`);
setMessage(response.samples.length ? null : "Zu diesem Landwirt wurden noch keine Proben gefunden.");
}
async function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!farmerQuery.trim()) {
setMessage("Bitte einen Landwirt eingeben.");
return;
}
try {
const response = await apiGet<PortalSnapshot>(
`/portal/snapshot?farmerQuery=${encodeURIComponent(farmerQuery.trim())}`,
);
setFarmers(response.farmers);
if (!response.farmers.length) {
setSamples([]);
setResultLabel("Keine Treffer");
setMessage("Kein passender Landwirt gefunden.");
return;
}
if (response.farmers.length === 1) {
await loadFarmerSamples(response.farmers[0]);
return;
}
setSamples([]);
setResultLabel("Landwirt auswaehlen");
setMessage(null);
} catch (searchError) {
setFarmers([]);
setSamples([]);
setResultLabel("Keine Treffer");
setMessage((searchError as Error).message);
}
}
async function openSample(sampleNumber: number) {
try {
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber}`);
const target = routeForLookup(result);
if (!result.found || !target) {
setMessage(result.message);
return;
}
navigate(target);
} catch (openError) {
setMessage((openError as Error).message);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Suche | Landwirt</p>
<h3>Proben nach Landwirt finden</h3>
<p className="muted-text">
Suche nach dem Landwirt und oeffne danach eine der zugehoerigen Proben direkt aus der Trefferliste.
</p>
</div>
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="section-card">
<form className={`hero-card__form ${showValidation ? "show-validation" : ""}`} onSubmit={handleSearch}>
<label className="field field--required">
<span>Landwirt</span>
<input
value={farmerQuery}
onChange={(event) => setFarmerQuery(event.target.value)}
placeholder="z. B. Agrar Lindenblick"
required
/>
</label>
<button type="submit" className="accent-button">
Landwirt suchen
</button>
</form>
</section>
{farmers.length > 1 ? (
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Treffer</p>
<h3>Gefundene Landwirte</h3>
</div>
</div>
<div className="user-grid">
{farmers.map((farmer) => (
<button
key={farmer.businessKey}
type="button"
className="user-card"
onClick={() => void loadFarmerSamples(farmer)}
>
<strong>{farmer.name}</strong>
<small>{farmer.email ?? "ohne E-Mail"}</small>
</button>
))}
</div>
</section>
) : null}
<SampleSearchResultsSection
eyebrow="Suchergebnisse"
title={resultLabel}
emptyText="Bitte zuerst einen Landwirt suchen oder aus der Trefferliste auswaehlen."
samples={samples}
onOpen={(sampleNumber) => void openSample(sampleNumber)}
/>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import SampleSearchResultsSection from "../components/SampleSearchResultsSection";
import { apiGet } from "../lib/api";
import type { LookupResult, PortalSampleRow, SampleDetail } from "../lib/types";
function routeForLookup(result: LookupResult) {
return result.sampleId && result.routeSegment ? `/samples/${result.sampleId}/${result.routeSegment}` : null;
}
function toPortalRow(sample: SampleDetail): PortalSampleRow {
return {
sampleId: sample.id,
sampleNumber: sample.sampleNumber,
createdAt: sample.createdAt,
completedAt: sample.completedAt,
farmerBusinessKey: sample.farmerBusinessKey,
farmerName: sample.farmerName,
farmerEmail: sample.farmerEmail,
cowNumber: sample.cowNumber,
cowName: sample.cowName,
sampleKindLabel: sample.sampleKind,
internalNote: sample.therapy?.internalNote ?? null,
completed: sample.completed,
reportSent: sample.reportSent,
reportBlocked: sample.reportBlocked,
};
}
export default function SearchPage() {
const navigate = useNavigate();
const [sampleNumber, setSampleNumber] = useState("");
const [samples, setSamples] = useState<PortalSampleRow[]>([]);
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const [resultLabel, setResultLabel] = useState("Bitte Probennummer eingeben");
async function handleSearchByNumber(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!sampleNumber.trim()) {
setMessage("Bitte eine Probennummer eingeben.");
return;
}
try {
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber.trim()}`);
if (!result.found || !result.sampleId) {
setMessage(result.message);
setSamples([]);
setResultLabel("Keine Treffer");
return;
}
const sample = await apiGet<SampleDetail>(`/samples/by-number/${sampleNumber.trim()}`);
setSamples([toPortalRow(sample)]);
setResultLabel(`Suchtreffer fuer Probe ${sample.sampleNumber}`);
setMessage(null);
} catch (searchError) {
setSamples([]);
setMessage((searchError as Error).message);
}
}
async function openSample(sampleNumberToOpen: number) {
try {
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumberToOpen}`);
const target = routeForLookup(result);
if (!result.found || !target) {
setMessage(result.message);
return;
}
navigate(target);
} catch (openError) {
setMessage((openError as Error).message);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Suche | Probe</p>
<h3>Probe per Nummer finden</h3>
<p className="muted-text">
Suche gezielt nach einer Probennummer und oeffne den passenden Arbeitsschritt direkt aus der Trefferliste.
</p>
</div>
{message ? <div className="alert alert--error">{message}</div> : null}
</section>
<section className="section-card">
<form className={`hero-card__form ${showValidation ? "show-validation" : ""}`} onSubmit={handleSearchByNumber}>
<label className="field field--required">
<span>Probennummer</span>
<input
value={sampleNumber}
onChange={(event) => setSampleNumber(event.target.value)}
inputMode="numeric"
placeholder="z. B. 100203"
required
/>
</label>
<button type="submit" className="accent-button">
Probe suchen
</button>
</form>
</section>
<SampleSearchResultsSection
eyebrow="Suchergebnisse"
title={resultLabel}
emptyText="Bitte eine Probennummer eingeben und die Suche starten."
samples={samples}
onOpen={(sampleNumberValue) => void openSample(sampleNumberValue)}
/>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserRow } from "../lib/types";
interface PrimaryUserRow {
id: string;
displayName: string;
companyName: string | null;
email: string | null;
active: boolean;
updatedAt: string;
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<PrimaryUserRow[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const isAdmin = user?.role === "ADMIN";
useEffect(() => {
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
// Nur Hauptnutzer (primaryUser=true) anzeigen, aber Admin ausblenden
const primaryUsers = response
.filter((u) => u.primaryUser && u.role !== "ADMIN")
.map((u) => ({
id: u.id,
displayName: u.displayName,
companyName: u.companyName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(primaryUsers);
} catch (error) {
setMessage((error as Error).message);
} finally {
setLoading(false);
}
}
void loadUsers();
}, []);
async function toggleUserStatus(userId: string, newStatus: boolean) {
try {
const userToUpdate = users.find((u) => u.id === userId);
if (!userToUpdate) return;
await apiPost("/portal/users", {
id: userId,
active: newStatus,
});
setUsers((current) =>
current.map((u) => (u.id === userId ? { ...u, active: newStatus } : u))
);
setMessage(
`Benutzer "${userToUpdate.displayName}" wurde ${
newStatus ? "freigegeben" : "gesperrt"
}.`
);
// Nachricht nach 3 Sekunden ausblenden
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
}
}
// Formatierungsfunktion für das Datum
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
// Nicht-Admin Ansicht (sollte nicht passieren, da Route geschützt ist)
if (!isAdmin) {
return (
<div className="page-stack">
<section className="section-card">
<div className="alert alert--error">
Zugriff verweigert. Diese Seite ist nur für Administratoren.
</div>
</section>
</div>
);
}
return (
<div className="page-stack">
{/* Header */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Benutzerverwaltung</p>
<h3>Hauptnutzer freigeben oder sperren</h3>
<p className="muted-text">
Verwalten Sie den Zugriff von Hauptnutzern auf das System.
Gesperrte Benutzer können sich nicht mehr anmelden.
</p>
</div>
</section>
{/* Status-Meldung */}
{message ? (
<div
className={
message.includes("freigegeben") || message.includes("gesperrt")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
) : null}
{/* Tabelle mit Hauptnutzern */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Hauptnutzer</p>
<h3>Registrierte Hauptnutzer</h3>
</div>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen...</div>
) : users.length === 0 ? (
<div className="empty-state">Keine Hauptnutzer vorhanden.</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Firma</th>
<th>E-Mail</th>
<th>Status</th>
<th>Letzte Änderung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{users.map((entry) => (
<tr key={entry.id} className={!entry.active ? "table-row--inactive" : ""}>
<td>
<strong>{entry.displayName}</strong>
</td>
<td>{entry.companyName ?? "-"}</td>
<td>{entry.email ?? "-"}</td>
<td>
<span
className={`status-pill ${
entry.active ? "status-pill--active" : "status-pill--inactive"
}`}
>
{entry.active ? "Freigegeben" : "Gesperrt"}
</span>
</td>
<td className="text-muted">{formatDate(entry.updatedAt)}</td>
<td>
<button
type="button"
className={`action-button ${
entry.active ? "action-button--danger" : "action-button--success"
}`}
onClick={() => toggleUserStatus(entry.id, !entry.active)}
>
{entry.active ? "Sperren" : "Freigeben"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Hauptnutzer sind die primären Kontoinhaber. Wenn Sie einen Hauptnutzer sperren,
können sich dieser und alle zugehörigen Nebennutzer nicht mehr anmelden.
Die Daten bleiben erhalten und können durch Freigabe wieder aktiviert werden.
</p>
</div>
</section>
</div>
);
}

View File

@@ -57,16 +57,21 @@ a {
display: grid;
grid-template-columns: minmax(228px, 280px) minmax(0, 1fr);
min-height: 100vh;
align-items: start;
}
.sidebar {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 28px 24px;
min-height: 100vh;
height: 100vh;
background: rgba(23, 34, 41, 0.92);
color: #f8f3ed;
backdrop-filter: blur(16px);
overflow: hidden;
}
.sidebar__brand {
@@ -89,8 +94,8 @@ a {
align-items: center;
justify-content: center;
width: 100%;
min-height: 108px;
padding: 16px 24px;
min-height: 54px;
padding: 8px 24px;
border-radius: 28px;
background: linear-gradient(135deg, #1d9485, #0f5b53);
font-weight: 700;
@@ -100,7 +105,9 @@ a {
.sidebar__nav {
display: grid;
gap: 10px;
margin: 32px 0 auto;
margin: 32px 0 0;
flex: 1 1 auto;
align-content: start;
}
.nav-link {
@@ -111,6 +118,50 @@ a {
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.nav-group {
display: grid;
gap: 8px;
}
.nav-group__label {
padding: 10px 16px 0;
color: rgba(248, 243, 237, 0.5);
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.74rem;
}
.nav-subnav {
display: grid;
gap: 8px;
padding-left: 14px;
}
.nav-subgroup {
display: grid;
gap: 6px;
}
.nav-subgroup__label {
padding: 4px 14px 0;
color: rgba(248, 243, 237, 0.56);
font-size: 0.78rem;
font-weight: 600;
}
.nav-subnav--nested {
gap: 6px;
padding-left: 12px;
}
.nav-sublink {
padding: 10px 14px;
border-radius: 14px;
color: rgba(248, 243, 237, 0.72);
text-decoration: none;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.nav-link:hover,
.nav-link.is-active {
background: rgba(255, 255, 255, 0.08);
@@ -118,9 +169,18 @@ a {
transform: translateX(4px);
}
.nav-sublink:hover,
.nav-sublink.is-active {
background: rgba(255, 255, 255, 0.08);
color: #fff8f0;
transform: translateX(4px);
}
.sidebar__footer {
display: grid;
gap: 12px;
margin-top: auto;
padding-top: 24px;
}
.user-chip {
@@ -252,6 +312,7 @@ a {
.portal-grid {
display: grid;
gap: 20px;
align-items: stretch;
}
.metrics-grid {
@@ -263,6 +324,14 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid--stacked {
grid-template-columns: 1fr;
}
.portal-grid > :only-child {
grid-column: 1 / -1;
}
.metric-card {
padding: 22px 24px;
border-radius: var(--radius-lg);
@@ -302,16 +371,40 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-grid--stacked {
grid-template-columns: 1fr;
}
.field {
display: grid;
gap: 8px;
}
.field--wide {
grid-column: 1 / -1;
}
.field--spaced {
margin-top: 28px;
}
.field span {
font-size: 0.9rem;
color: var(--muted);
}
.field--required span::after,
.required-label::after {
content: " *";
color: var(--danger);
}
.required-label {
margin: 0 0 12px;
color: var(--muted);
font-size: 0.9rem;
}
.field input,
.field select,
.field textarea,
@@ -330,6 +423,21 @@ a {
resize: vertical;
}
.show-validation .field input:invalid,
.show-validation .field select:invalid,
.show-validation .field textarea:invalid,
.field input.is-invalid,
.field select.is-invalid,
.field textarea.is-invalid,
.data-table input.is-invalid,
.data-table select.is-invalid,
.pathogen-grid.is-invalid,
.choice-row.is-invalid,
.tab-chip.is-invalid {
border-color: rgba(157, 60, 48, 0.55);
box-shadow: 0 0 0 3px rgba(157, 60, 48, 0.12);
}
.choice-row,
.tab-row,
.page-actions,
@@ -364,6 +472,10 @@ a {
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32);
}
.tab-chip.is-invalid {
color: var(--danger);
}
.section-card__header {
display: flex;
align-items: center;
@@ -393,13 +505,19 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pathogen-grid.is-invalid {
padding: 14px;
border: 1px solid rgba(157, 60, 48, 0.3);
border-radius: 18px;
}
.user-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 18px;
}
.auth-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
margin-top: 18px;
}
@@ -551,6 +669,10 @@ a {
color: var(--muted);
}
.info-panel--spaced {
margin-top: 28px;
}
.alert {
border: 1px solid transparent;
}
@@ -582,10 +704,10 @@ a {
.login-hero {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
grid-template-columns: 1fr;
gap: 28px;
align-items: stretch;
width: min(1240px, 100%);
width: min(960px, 100%);
}
.login-hero__copy {
@@ -645,7 +767,645 @@ a {
justify-content: flex-end;
}
.invoice-template-page {
min-height: 0;
overflow: hidden;
}
.invoice-template-page__card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: 100%;
padding: 20px;
background: var(--surface-strong);
box-shadow: none;
backdrop-filter: none;
overflow: hidden;
}
.invoice-template-page__card .section-card__header {
margin-bottom: 12px;
}
.invoice-template-page__card .page-actions {
margin-top: 0;
}
.invoice-template {
display: grid;
grid-template-columns: minmax(230px, 280px) minmax(0, 1fr) minmax(260px, 320px);
gap: 24px;
align-items: start;
min-height: 0;
height: 100%;
overflow: hidden;
}
.invoice-template__panel,
.invoice-template__canvas-column {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 18px;
min-height: 0;
}
.invoice-template__panel {
position: sticky;
top: 18px;
overflow: hidden;
}
.invoice-template__panel-header,
.invoice-template__canvas-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.invoice-template__panel-header h4,
.invoice-template__canvas-header h4 {
margin: 0;
}
.invoice-template__panel-note {
color: var(--muted);
font-size: 0.82rem;
text-align: right;
}
.invoice-template__palette {
display: grid;
gap: 18px;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
align-content: start;
scrollbar-gutter: stable;
}
.invoice-template__palette-group {
display: grid;
gap: 10px;
}
.invoice-template__palette-group-header h5 {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.invoice-template__palette-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invoice-template__tile {
display: grid;
gap: 6px;
padding: 12px 14px;
border: 1px solid rgba(37, 49, 58, 0.1);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
text-align: left;
cursor: grab;
transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
}
.invoice-template__tile:hover {
transform: translateY(-1px);
box-shadow: 0 18px 36px rgba(54, 44, 27, 0.1);
border-color: rgba(17, 109, 99, 0.22);
}
.invoice-template__tile strong,
.invoice-template__tile span,
.invoice-template__tile small {
display: block;
}
.invoice-template__tile span {
color: var(--text);
font-size: 0.84rem;
line-height: 1.3;
word-break: break-word;
}
.invoice-template__tile small {
color: var(--muted);
font-size: 0.8rem;
line-height: 1.5;
}
.invoice-template__canvas-shell {
position: relative;
display: flex;
justify-content: center;
align-items: flex-start;
height: 100%;
min-height: 0;
overflow: hidden;
padding: 0 0 10px;
box-sizing: border-box;
}
.invoice-template__canvas-stage {
position: relative;
flex: 0 0 auto;
overflow: visible;
}
.invoice-template__canvas {
position: relative;
width: 794px;
min-width: 794px;
height: 1123px;
border: 1px dashed rgba(17, 109, 99, 0.22);
border-radius: 0;
background:
linear-gradient(180deg, rgba(17, 109, 99, 0.03), transparent 18%),
linear-gradient(transparent 31px, rgba(37, 49, 58, 0.05) 32px),
linear-gradient(90deg, transparent 31px, rgba(37, 49, 58, 0.05) 32px),
#fffdf9;
background-size: auto, 32px 32px, 32px 32px, auto;
overflow: hidden;
transform-origin: top left;
will-change: transform;
}
.invoice-template__canvas.is-active {
border-color: rgba(17, 109, 99, 0.48);
box-shadow: 0 0 0 6px rgba(17, 109, 99, 0.08);
}
.invoice-template__canvas::before {
content: "";
position: absolute;
inset: 28px;
border: 1px solid rgba(37, 49, 58, 0.05);
border-radius: 0;
pointer-events: none;
}
.invoice-template__canvas-empty {
display: grid;
place-items: center;
height: 100%;
padding: 48px;
color: var(--muted);
text-align: center;
line-height: 1.7;
}
.invoice-template__canvas-element {
position: absolute;
display: grid;
gap: 0;
padding: 4px 5px;
border: 1px solid transparent;
border-radius: 16px;
background: transparent;
color: var(--text);
cursor: move;
box-shadow: none;
overflow: visible;
user-select: none;
}
.invoice-template__canvas-element--line,
.invoice-template__canvas-element--image {
padding: 0;
}
.invoice-template__canvas-element:hover,
.invoice-template__canvas-element.is-selected {
border-color: rgba(17, 109, 99, 0.28);
background: rgba(17, 109, 99, 0.08);
}
.invoice-template__canvas-element.is-selected {
border-radius: 0;
}
.invoice-template__canvas-element.is-dragging {
cursor: grabbing;
z-index: 2;
}
.invoice-template__canvas-element.is-resizing {
cursor: nwse-resize;
z-index: 3;
}
.invoice-template__canvas-element-text {
display: block;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
}
.invoice-template__canvas-line {
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background: rgba(37, 49, 58, 0.82);
pointer-events: none;
}
.invoice-template__canvas-image {
display: block;
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: contain;
pointer-events: none;
}
.invoice-template__canvas-image-placeholder {
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-height: 100%;
padding: 12px;
border: 1px dashed rgba(17, 109, 99, 0.28);
border-radius: 18px;
background: rgba(17, 109, 99, 0.05);
color: var(--muted);
font-size: 0.82rem;
line-height: 1.4;
pointer-events: none;
text-align: center;
}
.invoice-template__canvas-element-resize-handle {
position: absolute;
right: -7px;
bottom: -7px;
width: 14px;
height: 14px;
border: 1px solid rgba(17, 109, 99, 0.48);
border-radius: 999px;
background: var(--surface-strong);
box-shadow: 0 0 0 3px rgba(17, 109, 99, 0.12);
cursor: nwse-resize;
pointer-events: auto;
touch-action: none;
}
.invoice-template__inspector {
display: grid;
gap: 12px;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
align-content: start;
scrollbar-gutter: stable;
}
.invoice-template__inspector-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
flex-wrap: wrap;
}
.invoice-template-page .field-grid {
gap: 12px;
}
.invoice-template-page .field input,
.invoice-template-page .field select,
.invoice-template-page .field textarea {
padding: 10px 12px;
}
.invoice-template-page .field textarea {
min-height: 88px;
}
/* Admin Dashboard Styles */
.admin-hero {
background: linear-gradient(135deg, rgba(90, 123, 168, 0.15) 0%, rgba(74, 124, 89, 0.1) 100%);
}
.admin-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1024px) {
.admin-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.admin-metrics {
grid-template-columns: 1fr;
}
}
.metric-card--primary {
background: linear-gradient(135deg, rgba(90, 123, 168, 0.25) 0%, rgba(90, 123, 168, 0.1) 100%);
border-color: rgba(90, 123, 168, 0.3);
}
.metric-card__value--large {
font-size: 2.5rem;
background: linear-gradient(135deg, var(--text) 0%, #5b7ba8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.admin-modules-section {
margin-top: 8px;
}
.admin-modules-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 20px;
}
@media (max-width: 1024px) {
.admin-modules-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.admin-modules-grid {
grid-template-columns: 1fr;
}
}
.admin-module-card {
display: flex;
align-items: center;
gap: 16px;
padding: 24px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
border-left: 4px solid var(--module-color, #5b7ba8);
}
.admin-module-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.admin-module-card__icon {
font-size: 2rem;
line-height: 1;
}
.admin-module-card__content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.admin-module-card__content strong {
font-size: 1.1rem;
color: var(--text);
}
.admin-module-card__content .muted-text {
font-size: 0.85rem;
}
.admin-module-card__arrow {
font-size: 1.5rem;
color: var(--muted);
opacity: 0.5;
transition: opacity 0.2s ease;
}
.admin-module-card:hover .admin-module-card__arrow {
opacity: 1;
}
.quick-actions-section {
margin-top: 8px;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 20px;
}
@media (max-width: 1024px) {
.quick-actions-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.quick-actions-grid {
grid-template-columns: 1fr;
}
}
.quick-action-button {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.95rem;
color: var(--text);
}
.quick-action-button:hover {
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow);
}
.quick-action-button span:first-child {
font-size: 1.25rem;
}
/* User Management Table Styles */
.table-row--inactive {
background-color: rgba(157, 60, 48, 0.05);
}
.status-pill--active {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-pill--inactive {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.action-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button--success {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
}
.action-button--success:hover {
background-color: rgba(74, 124, 89, 0.25);
}
.action-button--danger {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
}
.action-button--danger:hover {
background-color: rgba(157, 60, 48, 0.25);
}
.text-muted {
color: var(--muted);
}
.metric-card--secondary {
background: linear-gradient(135deg, rgba(139, 90, 124, 0.2) 0%, rgba(139, 90, 124, 0.05) 100%);
border-color: rgba(139, 90, 124, 0.25);
}
.sample-count {
display: inline-block;
min-width: 32px;
padding: 4px 12px;
background: rgba(90, 123, 168, 0.15);
border-radius: 16px;
font-weight: 600;
color: var(--text);
text-align: center;
}
/* Chart Container */
.chart-container {
position: relative;
height: 400px;
padding: 20px;
background: rgba(255, 255, 255, 0.5);
border-radius: var(--radius-xl);
}
@media (max-width: 768px) {
.chart-container {
height: 300px;
padding: 10px;
}
}
.dialog-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 28px;
background: rgba(29, 36, 40, 0.42);
backdrop-filter: blur(8px);
z-index: 50;
}
.dialog {
display: grid;
gap: 18px;
width: min(1120px, 100%);
max-height: calc(100vh - 56px);
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.42);
border-radius: var(--radius-xl);
background: rgba(255, 248, 240, 0.96);
box-shadow: var(--shadow);
}
.dialog--wide {
width: min(1180px, 100%);
}
.dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.dialog__header h4 {
margin: 0;
}
.dialog__actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.dialog__actions a {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.dialog__body {
min-height: 0;
}
.dialog__body--pdf {
height: min(80vh, 900px);
}
.dialog__frame {
width: 100%;
height: 100%;
border: none;
border-radius: 20px;
background: white;
}
@media (max-width: 1200px) {
.invoice-template-page,
.invoice-template-page__card {
height: auto;
overflow: visible;
}
.app-shell {
grid-template-columns: 240px minmax(0, 1fr);
}
@@ -657,10 +1417,25 @@ a {
.portal-grid,
.form-grid,
.field-grid,
.metrics-grid {
.metrics-grid,
.invoice-template {
grid-template-columns: 1fr;
}
.invoice-template__panel {
position: static;
}
.invoice-template__palette,
.invoice-template__inspector {
overflow: visible;
padding-right: 0;
}
.invoice-template__palette-grid {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.quarter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -690,4 +1465,27 @@ a {
.quarter-grid {
grid-template-columns: 1fr;
}
.invoice-template__canvas-shell {
margin-inline: 0;
padding-inline: 0;
overflow: hidden;
}
.dialog-backdrop {
padding: 16px;
}
.dialog {
padding: 18px;
}
.dialog__header {
align-items: flex-start;
flex-direction: column;
}
.dialog__body--pdf {
height: 72vh;
}
}

View File

@@ -1,4 +1,5 @@
interface ImportMetaEnv {
readonly DEV: boolean;
readonly VITE_API_URL?: string;
}