Compare commits

..

10 Commits

57 changed files with 6158 additions and 788 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/node_modules/
frontend/dist/ frontend/dist/
.DS_Store .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 ## 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: Optional fuer echten Mailversand im Portal:
- `MUH_MAIL_ENABLED=true` - `MUH_MAIL_ENABLED=true`
- `MUH_MAIL_FROM=...`
- `MUH_MAIL_HOST=...` - `MUH_MAIL_HOST=...`
- `MUH_MAIL_PORT=587` - `MUH_MAIL_PORT=587`
- `MUH_MAIL_USERNAME=...` - `MUH_MAIL_USERNAME=...`
- `MUH_MAIL_PASSWORD=...` - `MUH_MAIL_PASSWORD=...`
- `MUH_MAIL_FROM=...` - `MUH_MAIL_PROTOCOL=smtp`
- `MUH_MAIL_AUTH=true` - `MUH_MAIL_AUTH=true`
- `MUH_MAIL_STARTTLS=true` - `MUH_MAIL_STARTTLS=true`
@@ -52,13 +60,34 @@ Frontend-URL:
Optional kann die API-URL im Frontend ueber `VITE_API_URL` gesetzt werden. 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 ## Anmeldung
Es gibt jetzt drei Varianten: Es gibt jetzt zwei Varianten:
- Schnelllogin ueber Benutzerkuerzel
- Login ueber E-Mail oder Benutzername plus Passwort - 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: Vordefinierter Admin:

View File

@@ -17,7 +17,7 @@
<description>MUH application backend</description> <description>MUH application backend</description>
<properties> <properties>
<java.version>17</java.version> <java.version>21</java.version>
</properties> </properties>
<dependencies> <dependencies>
@@ -37,6 +37,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId> <artifactId>spring-boot-starter-mail</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId> <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") @Document("users")
public record AppUser( public record AppUser(
@Id String id, @Id String id,
String code, String accountId,
Boolean primaryUser,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String portalLogin, String phoneNumber,
String passwordHash, String passwordHash,
boolean active, boolean active,
UserRole role, 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 createdAt,
LocalDateTime updatedAt, LocalDateTime updatedAt,
LocalDateTime completedAt, LocalDateTime completedAt,
String ownerAccountId,
String createdByUserCode, String createdByUserCode,
String createdByDisplayName 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> { public interface AppUserRepository extends MongoRepository<AppUser, String> {
List<AppUser> findByActiveTrueOrderByDisplayNameAsc(); List<AppUser> findByActiveTrueOrderByDisplayNameAsc();
Optional<AppUser> findByCodeIgnoreCase(String code); List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
Optional<AppUser> findByEmailIgnoreCase(String email); 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> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey);
List<Sample> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime start, LocalDateTime end);
List<Sample> findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end); List<Sample> findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end);
List<Sample> findByCompletedAtNotNullOrderByCompletedAtDesc(); 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

@@ -13,21 +13,26 @@ import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.FarmerRepository; import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository; import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository; 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.http.HttpStatus;
import org.springframework.lang.NonNull; 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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -60,6 +65,9 @@ public class CatalogService {
private final PathogenCatalogRepository pathogenRepository; private final PathogenCatalogRepository pathogenRepository;
private final AntibioticCatalogRepository antibioticRepository; private final AntibioticCatalogRepository antibioticRepository;
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService;
private final AuthorizationService authorizationService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService( public CatalogService(
@@ -67,13 +75,19 @@ public class CatalogService {
MedicationCatalogRepository medicationRepository, MedicationCatalogRepository medicationRepository,
PathogenCatalogRepository pathogenRepository, PathogenCatalogRepository pathogenRepository,
AntibioticCatalogRepository antibioticRepository, AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository AppUserRepository appUserRepository,
MongoTemplate mongoTemplate,
AuthTokenService authTokenService,
AuthorizationService authorizationService
) { ) {
this.farmerRepository = farmerRepository; this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository; this.medicationRepository = medicationRepository;
this.pathogenRepository = pathogenRepository; this.pathogenRepository = pathogenRepository;
this.antibioticRepository = antibioticRepository; this.antibioticRepository = antibioticRepository;
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService;
this.authorizationService = authorizationService;
} }
public ActiveCatalogSummary activeCatalogSummary() { public ActiveCatalogSummary activeCatalogSummary() {
@@ -82,11 +96,12 @@ public class CatalogService {
medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(), medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(),
pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(), pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(),
antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).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()); return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows());
} }
@@ -118,7 +133,8 @@ public class CatalogService {
.toList(); .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) { for (FarmerMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.name())) {
continue; continue;
@@ -181,7 +197,8 @@ public class CatalogService {
return listFarmerRows(); 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) { for (MedicationMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.category() == null) { if (isBlank(mutation.name()) || mutation.category() == null) {
continue; continue;
@@ -244,7 +261,8 @@ public class CatalogService {
return listMedicationRows(); 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) { for (PathogenMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.kind() == null) { if (isBlank(mutation.name()) || mutation.kind() == null) {
continue; continue;
@@ -312,7 +330,8 @@ public class CatalogService {
return listPathogenRows(); 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) { for (AntibioticMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.name())) {
continue; continue;
@@ -375,32 +394,51 @@ public class CatalogService {
return listAntibioticRows(); return listAntibioticRows();
} }
public List<UserRow> listUsers() { public List<UserRow> listUsers(String actorId) {
AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
ensureDefaultUsers(); ensureDefaultUsers();
return appUserRepository.findAll().stream() List<AppUser> users = actor.role() == UserRole.ADMIN
? appUserRepository.findAll()
: appUserRepository.findByAccountIdOrderByDisplayNameAsc(resolveAccountId(actor));
return users.stream()
.map(this::toUserRow) .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(); .toList();
} }
public UserRow createOrUpdateUser(UserMutation mutation) { public UserRow createOrUpdateUser(String actorId, UserMutation mutation) {
if (isBlank(mutation.displayName()) || isBlank(mutation.code())) { AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername und Kürzel sind erforderlich"); if (isBlank(mutation.displayName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername ist erforderlich");
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
validateUserMutation(mutation); validateUserMutation(actor, mutation);
if (isBlank(mutation.id())) { 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( AppUser created = appUserRepository.save(new AppUser(
null, userId,
mutation.code().trim().toUpperCase(), adminManaged ? userId : resolveAccountId(actor),
adminManaged,
mutation.displayName().trim(), mutation.displayName().trim(),
blankToNull(mutation.companyName()), adminManaged ? blankToNull(mutation.companyName()) : null,
blankToNull(mutation.address()), 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()), normalizeEmail(mutation.email()),
blankToNull(mutation.portalLogin()), adminManaged ? blankToNull(mutation.phoneNumber()) : null,
encodeIfPresent(mutation.password()), encodeIfPresent(mutation.password()),
mutation.active(), mutation.active(),
Optional.ofNullable(mutation.role()).orElse(UserRole.APP), adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
now, now,
now now
)); ));
@@ -409,41 +447,73 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt"); String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt");
AppUser existing = appUserRepository.findById(mutationId) AppUser existing = appUserRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
requireUserManagementAccess(actor, existing, true);
AppUser saved = appUserRepository.save(new AppUser( AppUser saved = appUserRepository.save(new AppUser(
existing.id(), existing.id(),
mutation.code().trim().toUpperCase(), existing.accountId(),
isPrimaryUser(existing),
mutation.displayName().trim(), mutation.displayName().trim(),
blankToNull(mutation.companyName()), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.companyName()) : existing.companyName(),
blankToNull(mutation.address()), 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()), 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()), isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(), 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(), existing.createdAt(),
now now
)); ));
return toUserRow(saved); return toUserRow(saved);
} }
public void deleteUser(String id) { public void deleteUser(String actorId, String id) {
appUserRepository.deleteById(requireText(id, "Benutzer-ID fehlt")); 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)) { if (isBlank(newPassword)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Passwort darf nicht leer sein"); 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")); .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( appUserRepository.save(new AppUser(
existing.id(), existing.id(),
existing.code(), existing.accountId(),
isPrimaryUser(existing),
existing.displayName(), existing.displayName(),
existing.companyName(), existing.companyName(),
existing.address(), existing.address(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(), existing.email(),
existing.portalLogin(), existing.phoneNumber(),
passwordEncoder.encode(newPassword), passwordEncoder.encode(newPassword),
existing.active(), existing.active(),
existing.role(), existing.role(),
@@ -452,29 +522,31 @@ public class CatalogService {
)); ));
} }
public UserOption loginByCode(String code) { public SessionResponse loginWithPassword(String email, String password) {
AppUser user = activeQuickLoginUsers().stream() if (isBlank(email) || isBlank(password)) {
.filter(candidate -> candidate.code().equalsIgnoreCase(code)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "E-Mail und Passwort sind erforderlich");
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzerkürzel unbekannt"));
return toUserOption(user);
}
public UserOption loginWithPassword(String identifier, String password) {
if (isBlank(identifier) || isBlank(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername/E-Mail und Passwort sind erforderlich");
} }
AppUser user = resolvePasswordUser(identifier.trim()) AppUser user = resolvePasswordUser(email.trim())
.filter(AppUser::active) .filter(AppUser::active)
.filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash())) .filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash()))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Anmeldung fehlgeschlagen")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Anmeldung fehlgeschlagen"));
return toUserOption(user); return toSessionResponse(user);
} }
public UserOption registerCustomer(RegistrationMutation mutation) { public SessionResponse registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName()) || isBlank(mutation.address()) || isBlank(mutation.email()) || isBlank(mutation.password())) { if (isBlank(mutation.companyName())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Firmenname, Adresse, E-Mail und Passwort sind erforderlich"); || 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()); String normalizedEmail = normalizeEmail(mutation.email());
@@ -483,45 +555,69 @@ public class CatalogService {
} }
String companyName = mutation.companyName().trim(); 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 displayName = companyName;
String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail));
String code = generateUniqueCode("K" + companyName);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(),
null, null,
code, true,
displayName, displayName,
companyName, companyName,
address, address,
street,
houseNumber,
postalCode,
city,
normalizedEmail, normalizedEmail,
portalLogin, phoneNumber,
passwordEncoder.encode(mutation.password()), passwordEncoder.encode(mutation.password()),
true, true,
UserRole.CUSTOMER, UserRole.CUSTOMER,
now, now,
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() { public UserOption currentUser(String actorId) {
ensureDefaultUsers(); AppUser user = appUserRepository.findById(requireText(actorId, "Benutzer-ID fehlt"))
return appUserRepository.findByActiveTrueOrderByDisplayNameAsc(); .filter(AppUser::active)
} .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Benutzer nicht authentifiziert"));
return toUserOption(user);
private List<AppUser> activeQuickLoginUsers() {
return activeUsers().stream()
.filter(user -> user.role() != UserRole.CUSTOMER)
.toList();
} }
public void ensureDefaultUsers() { public void ensureDefaultUsers() {
ensureDefaultUser("ADM", "Administrator", "admin@muh.local", "admin", "Admin123!", UserRole.ADMIN); migrateLegacyAppUsers();
ensureDefaultUser("SV", "Sven", "sven@muh.local", "sven", "muh123", UserRole.APP); removeLegacyUserCodeField();
ensureDefaultUser("AK", "Anna", "anna@muh.local", "anna", "muh123", UserRole.APP); backfillDefaultUserEmails();
ensureDefaultUser("LH", "Lena", "lena@muh.local", "lena", "muh123", UserRole.APP); removeLegacyPortalLoginField();
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
} }
public Farmer requireActiveFarmer(String businessKey) { public Farmer requireActiveFarmer(String businessKey) {
@@ -594,14 +690,18 @@ public class CatalogService {
private UserRow toUserRow(AppUser user) { private UserRow toUserRow(AppUser user) {
return new UserRow( return new UserRow(
user.id(), user.id(),
user.code(), isPrimaryUser(user),
user.displayName(), user.displayName(),
user.companyName(), user.companyName(),
user.address(), resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(), user.email(),
user.portalLogin(), user.phoneNumber(),
user.active(), user.active(),
user.role(), normalizeStoredRole(user.role()),
user.updatedAt() user.updatedAt()
); );
} }
@@ -625,16 +725,24 @@ public class CatalogService {
private UserOption toUserOption(AppUser user) { private UserOption toUserOption(AppUser user) {
return new UserOption( return new UserOption(
user.id(), user.id(),
user.code(), isPrimaryUser(user),
user.displayName(), user.displayName(),
user.companyName(), user.companyName(),
user.address(), resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(), user.email(),
user.portalLogin(), user.phoneNumber(),
user.role() normalizeStoredRole(user.role())
); );
} }
private SessionResponse toSessionResponse(AppUser user) {
return new SessionResponse(authTokenService.createToken(user), toUserOption(user));
}
private String encodeIfPresent(String password) { private String encodeIfPresent(String password) {
return isBlank(password) ? null : passwordEncoder.encode(password); return isBlank(password) ? null : passwordEncoder.encode(password);
} }
@@ -647,29 +755,16 @@ public class CatalogService {
return Objects.requireNonNull(sanitized); return Objects.requireNonNull(sanitized);
} }
private void validateUserMutation(UserMutation mutation) { private void validateUserMutation(AppUser actor, UserMutation mutation) {
String normalizedEmail = normalizeEmail(mutation.email()); String normalizedEmail = normalizeEmail(mutation.email());
String normalizedLogin = blankToNull(mutation.portalLogin());
String normalizedCode = mutation.code().trim().toUpperCase(Locale.ROOT);
appUserRepository.findAll().forEach(existing -> { 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 if (normalizedEmail != null
&& existing.email() != null && existing.email() != null
&& !safeEquals(existing.id(), blankToNull(mutation.id())) && !safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.email().equalsIgnoreCase(normalizedEmail)) { && existing.email().equalsIgnoreCase(normalizedEmail)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Diese E-Mail-Adresse ist bereits vergeben"); 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); return left == null ? right == null : left.equals(right);
} }
private Optional<AppUser> resolvePasswordUser(String identifier) { private Optional<AppUser> resolvePasswordUser(String email) {
return appUserRepository.findByEmailIgnoreCase(identifier) return appUserRepository.findByEmailIgnoreCase(normalizeEmail(email));
.or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier)); }
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( private void ensureDefaultUser(
String code,
String displayName, String displayName,
String email, String email,
String portalLogin,
String rawPassword, String rawPassword,
UserRole role UserRole role
) { ) {
boolean exists = appUserRepository.findByCodeIgnoreCase(code).isPresent() boolean exists = appUserRepository.findByEmailIgnoreCase(email).isPresent();
|| appUserRepository.findByEmailIgnoreCase(email).isPresent()
|| appUserRepository.findByPortalLoginIgnoreCase(portalLogin).isPresent();
if (exists) { if (exists) {
return; return;
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String userId = UUID.randomUUID().toString();
appUserRepository.save(new AppUser( appUserRepository.save(new AppUser(
null, userId,
code, userId,
true,
displayName, displayName,
null, null,
null, null,
null,
null,
null,
null,
email, email,
portalLogin, null,
passwordEncoder.encode(rawPassword), passwordEncoder.encode(rawPassword),
true, true,
role, 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) { private String normalizeEmail(String email) {
return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT); return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT);
} }
private String localPart(String email) { private UserRole normalizeStoredRole(UserRole role) {
int separator = email.indexOf('@'); return role == null || role == UserRole.APP ? UserRole.CUSTOMER : role;
return separator >= 0 ? email.substring(0, separator) : email;
} }
private String generateUniquePortalLogin(String seed) { private UserRole normalizeManagedRole(UserRole role) {
String base = seed.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]", ""); return role == null || role == UserRole.APP ? UserRole.CUSTOMER : role;
if (base.isBlank()) {
base = "user";
}
String candidate = base;
int index = 2;
while (appUserRepository.findByPortalLoginIgnoreCase(candidate).isPresent()) {
candidate = base + index++;
}
return candidate;
} }
private String generateUniqueCode(String seed) { private String resolveAddress(AppUser user) {
String compact = seed.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); if (!isBlank(user.address())) {
if (compact.isBlank()) { return user.address();
compact = "USR";
} }
if (isBlank(user.street()) && isBlank(user.houseNumber()) && isBlank(user.postalCode()) && isBlank(user.city())) {
String base = compact.length() >= 3 ? compact.substring(0, Math.min(4, compact.length())) : (compact + "XXX").substring(0, 3); return null;
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;
} }
return formatAddress(user.street(), user.houseNumber(), user.postalCode(), user.city());
}
String prefix = candidate.substring(0, Math.min(2, candidate.length())); private String buildAddress(String street, String houseNumber, String postalCode, String city) {
int index = 1; if (isBlank(street) && isBlank(houseNumber) && isBlank(postalCode) && isBlank(city)) {
while (true) { return null;
String numbered = (prefix + index).toUpperCase(Locale.ROOT);
if (!usedCodes.contains(numbered)) {
return numbered;
}
index++;
} }
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( public record ActiveCatalogSummary(
@@ -805,12 +977,16 @@ public class CatalogService {
public record UserOption( public record UserOption(
String id, String id,
String code, boolean primaryUser,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String portalLogin, String phoneNumber,
UserRole role UserRole role
) { ) {
} }
@@ -870,12 +1046,16 @@ public class CatalogService {
public record UserRow( public record UserRow(
String id, String id,
String code, boolean primaryUser,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String portalLogin, String phoneNumber,
boolean active, boolean active,
UserRole role, UserRole role,
LocalDateTime updatedAt LocalDateTime updatedAt
@@ -884,12 +1064,15 @@ public class CatalogService {
public record UserMutation( public record UserMutation(
String id, String id,
String code,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String portalLogin, String phoneNumber,
String password, String password,
boolean active, boolean active,
UserRole role UserRole role
@@ -898,9 +1081,16 @@ public class CatalogService {
public record RegistrationMutation( public record RegistrationMutation(
String companyName, String companyName,
String address, String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber,
String password 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; 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() List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary().farmers().stream()
.filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT))) .filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT)))
.toList(); .toList();
List<PortalSampleRow> sampleRows; List<PortalSampleRow> sampleRows;
if (sampleNumber != null) { if (sampleNumber != null) {
sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(sampleNumber))); sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(actorId, sampleNumber)));
} else if (farmerBusinessKey != null && !farmerBusinessKey.isBlank()) { } 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)) .filter(sample -> cowQuery == null || cowQuery.isBlank() || cowMatches(sample, cowQuery))
.map(this::toPortalRow) .map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed()) .sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
.toList(); .toList();
} else if (date != null) { } else if (date != null) {
sampleRows = sampleService.samplesByDate(date).stream() sampleRows = sampleService.samplesByDate(actorId, date).stream()
.map(this::toPortalRow) .map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder()))) .sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.toList(); .toList();
} else { } else {
sampleRows = sampleService.completedSamples().stream() sampleRows = sampleService.completedSamples(actorId).stream()
.limit(25) .limit(25)
.map(this::toPortalRow) .map(this::toPortalRow)
.toList(); .toList();
@@ -50,11 +58,18 @@ public class PortalService {
return new PortalSnapshot( return new PortalSnapshot(
matchingFarmers, matchingFarmers,
sampleRows, sampleRows,
reportService.reportCandidates(), reportService.reportCandidates(actorId),
catalogService.listUsers() 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) { private boolean cowMatches(Sample sample, String cowQuery) {
String query = cowQuery.toLowerCase(Locale.ROOT); String query = cowQuery.toLowerCase(Locale.ROOT);
return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query)) return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query))

View File

@@ -1,57 +1,72 @@
package de.svencarstensen.muh.service; package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.Sample; 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.ObjectProvider;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service @Service
public class ReportService { public class ReportService {
private final SampleService sampleService; private final SampleService sampleService;
private final AppUserRepository appUserRepository;
private final ObjectProvider<JavaMailSender> mailSenderProvider; private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final boolean mailEnabled; private final boolean mailEnabled;
private final String mailFrom; private final String mailFrom;
private final String reportMailTemplate;
public ReportService( public ReportService(
SampleService sampleService, SampleService sampleService,
AppUserRepository appUserRepository,
ObjectProvider<JavaMailSender> mailSenderProvider, ObjectProvider<JavaMailSender> mailSenderProvider,
@Value("classpath:mail/report-mail-template.txt") Resource reportMailTemplateResource,
@Value("${muh.mail.enabled:false}") boolean mailEnabled, @Value("${muh.mail.enabled:false}") boolean mailEnabled,
@Value("${muh.mail.from:no-reply@muh.local}") String mailFrom @Value("${muh.mail.from:no-reply@muh.local}") String mailFrom
) { ) {
this.sampleService = sampleService; this.sampleService = sampleService;
this.appUserRepository = appUserRepository;
this.mailSenderProvider = mailSenderProvider; this.mailSenderProvider = mailSenderProvider;
this.mailEnabled = mailEnabled; this.mailEnabled = mailEnabled;
this.mailFrom = mailFrom; this.mailFrom = mailFrom;
this.reportMailTemplate = loadTemplate(reportMailTemplateResource);
} }
public List<ReportCandidate> reportCandidates() { public List<ReportCandidate> reportCandidates(String actorId) {
return sampleService.completedSamples().stream() return sampleService.completedSamples(actorId).stream()
.filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank()) .filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank())
.map(this::toCandidate) .map(this::toCandidate)
.toList(); .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> sent = new ArrayList<>();
List<ReportCandidate> skipped = new ArrayList<>(); List<ReportCandidate> skipped = new ArrayList<>();
for (String sampleId : sampleIds) { for (String sampleId : sampleIds) {
Sample sample = sampleService.loadSampleEntity(sampleId); Sample sample = sampleService.loadSampleEntity(actorId, sampleId);
if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) { if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) {
skipped.add(toCandidate(sample)); skipped.add(toCandidate(sample));
continue; continue;
@@ -59,7 +74,7 @@ public class ReportService {
byte[] pdf = buildPdf(sample); byte[] pdf = buildPdf(sample);
if (mailEnabled && mailSenderProvider.getIfAvailable() != null) { if (mailEnabled && mailSenderProvider.getIfAvailable() != null) {
sendMail(sample, pdf); sendMail(sample, pdf, customerSignature);
} }
Sample updated = sampleService.markReportSent(sample.id(), LocalDateTime.now()); Sample updated = sampleService.markReportSent(sample.id(), LocalDateTime.now());
sent.add(toCandidate(updated)); sent.add(toCandidate(updated));
@@ -67,15 +82,16 @@ public class ReportService {
return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null); return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null);
} }
public byte[] reportPdf(String sampleId) { public byte[] reportPdf(String actorId, String sampleId) {
return buildPdf(sampleService.loadSampleEntity(sampleId)); return buildPdf(sampleService.loadSampleEntity(actorId, sampleId));
} }
public SampleService.SampleDetail toggleReportBlocked(String sampleId, boolean blocked) { public SampleService.SampleDetail toggleReportBlocked(String actorId, String sampleId, boolean blocked) {
return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id()); 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 { try {
JavaMailSender sender = mailSenderProvider.getIfAvailable(); JavaMailSender sender = mailSenderProvider.getIfAvailable();
if (sender == null) { if (sender == null) {
@@ -86,7 +102,7 @@ public class ReportService {
helper.setFrom(requireText(mailFrom, "Absender fehlt")); helper.setFrom(requireText(mailFrom, "Absender fehlt"));
helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt")); helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt"));
helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber()); 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))); helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf)));
sender.send(message); sender.send(message);
} catch (Exception exception) { } catch (Exception exception) {
@@ -136,6 +152,79 @@ public class ReportService {
return value == null || value.isBlank() ? "-" : value; 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) { private @NonNull String requireText(String value, String message) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); 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; package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AntibiogramEntry; import de.svencarstensen.muh.domain.AntibiogramEntry;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.PathogenCatalogItem; import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind; import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.QuarterAntibiogram; 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.SensitivityResult;
import de.svencarstensen.muh.domain.TherapyRecommendation; import de.svencarstensen.muh.domain.TherapyRecommendation;
import de.svencarstensen.muh.repository.SampleRepository; 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.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -25,33 +28,52 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
@Service @Service
public class SampleService { public class SampleService {
private final SampleRepository sampleRepository; private final SampleRepository sampleRepository;
private final CatalogService catalogService; 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.sampleRepository = sampleRepository;
this.catalogService = catalogService; this.catalogService = catalogService;
this.appUserRepository = appUserRepository;
this.authorizationService = authorizationService;
} }
public DashboardOverview dashboardOverview() { public DashboardOverview dashboardOverview(String actorId) {
List<SampleSummary> recent = sampleRepository.findTop12ByOrderByUpdatedAtDesc().stream() 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) .map(this::toSummary)
.toList(); .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(); LocalDate today = LocalDate.now();
long completedToday = sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc( long completedToday = accessibleSamples.stream()
today.atStartOfDay(), .filter(sample -> sample.completedAt() != null)
today.plusDays(1).atStartOfDay() .filter(sample -> !sample.completedAt().isBefore(today.atStartOfDay()))
).size(); .filter(sample -> sample.completedAt().isBefore(today.plusDays(1).atStartOfDay()))
.count();
return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent); 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) return sampleRepository.findBySampleNumber(sampleNumber)
.filter(sample -> canAccess(actor, sample))
.map(sample -> new LookupResult( .map(sample -> new LookupResult(
true, true,
"Probe gefunden", "Probe gefunden",
@@ -62,17 +84,20 @@ public class SampleService {
.orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null)); .orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null));
} }
public SampleDetail getSample(String id) { public SampleDetail getSample(String actorId, String id) {
return toDetail(loadSample(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) return sampleRepository.findBySampleNumber(sampleNumber)
.filter(sample -> canAccess(actor, sample))
.map(this::toDetail) .map(this::toDetail)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden")); .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(); LocalDateTime now = LocalDateTime.now();
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
@@ -99,6 +124,7 @@ public class SampleService {
now, now,
now, now,
null, null,
authorizationService.accountId(actor),
request.userCode(), request.userCode(),
request.userDisplayName() request.userDisplayName()
); );
@@ -106,8 +132,8 @@ public class SampleService {
return toDetail(sampleRepository.save(sample)); return toDetail(sampleRepository.save(sample));
} }
public SampleDetail saveRegistration(String id, RegistrationRequest request) { public SampleDetail saveRegistration(String actorId, String id, RegistrationRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditRegistration(existing)) { if (!SampleWorkflowRules.canEditRegistration(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden");
} }
@@ -137,6 +163,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
@@ -144,8 +171,8 @@ public class SampleService {
return toDetail(saved); return toDetail(saved);
} }
public SampleDetail saveAnamnesis(String id, AnamnesisRequest request) { public SampleDetail saveAnamnesis(String actorId, String id, AnamnesisRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditAnamnesis(existing)) { if (!SampleWorkflowRules.canEditAnamnesis(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden");
} }
@@ -203,14 +230,15 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
return toDetail(saved); return toDetail(saved);
} }
public SampleDetail saveAntibiogram(String id, AntibiogramRequest request) { public SampleDetail saveAntibiogram(String actorId, String id, AntibiogramRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (!SampleWorkflowRules.canEditAntibiogram(existing)) { if (!SampleWorkflowRules.canEditAntibiogram(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden");
} }
@@ -293,14 +321,15 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
return toDetail(saved); return toDetail(saved);
} }
public SampleDetail saveTherapy(String id, TherapyRequest request) { public SampleDetail saveTherapy(String actorId, String id, TherapyRequest request) {
Sample existing = loadSample(id); Sample existing = loadAccessibleSample(actorId, id);
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) { if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
TherapyRecommendation previous = existing.therapyRecommendation(); TherapyRecommendation previous = existing.therapyRecommendation();
TherapyRecommendation updated = previous == null TherapyRecommendation updated = previous == null
@@ -341,6 +370,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
))); )));
@@ -388,6 +418,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
LocalDateTime.now(), LocalDateTime.now(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
@@ -416,6 +447,7 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
@@ -443,21 +475,41 @@ public class SampleService {
existing.createdAt(), existing.createdAt(),
LocalDateTime.now(), LocalDateTime.now(),
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName()
)); ));
} }
public List<Sample> completedSamples() { public List<Sample> completedSamples(String actorId) {
return sampleRepository.findByCompletedAtNotNullOrderByCompletedAtDesc(); return accessibleSamples(requireActor(actorId)).stream()
.filter(sample -> sample.completedAt() != null)
.sorted(Comparator.comparing(Sample::completedAt).reversed())
.toList();
} }
public List<Sample> samplesByFarmerBusinessKey(String businessKey) { public List<Sample> samplesByFarmerBusinessKey(String actorId, String businessKey) {
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(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) { public List<Sample> samplesByCreatedDate(String actorId, LocalDate date) {
return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay()); 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() { public long nextSampleNumber() {
@@ -466,15 +518,119 @@ public class SampleService {
.orElse(100001L); .orElse(100001L);
} }
public Sample loadSampleEntity(String id) { public Sample loadSampleEntity(String actorId, String id) {
return loadSample(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) { private Sample loadSample(String id) {
ensureSampleOwnershipMigration();
return sampleRepository.findById(Objects.requireNonNull(id)) return sampleRepository.findById(Objects.requireNonNull(id))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden")); .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) { private SampleSummary toSummary(Sample sample) {
return new SampleSummary( return new SampleSummary(
sample.id(), sample.id(),

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.web; package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService; 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -16,9 +17,11 @@ import java.util.List;
public class CatalogController { public class CatalogController {
private final CatalogService catalogService; private final CatalogService catalogService;
private final SecuritySupport securitySupport;
public CatalogController(CatalogService catalogService) { public CatalogController(CatalogService catalogService, SecuritySupport securitySupport) {
this.catalogService = catalogService; this.catalogService = catalogService;
this.securitySupport = securitySupport;
} }
@GetMapping("/catalogs/summary") @GetMapping("/catalogs/summary")
@@ -28,47 +31,47 @@ public class CatalogController {
@GetMapping("/admin") @GetMapping("/admin")
public CatalogService.AdministrationOverview administrationOverview() { public CatalogService.AdministrationOverview administrationOverview() {
return catalogService.administrationOverview(); return catalogService.administrationOverview(securitySupport.currentUser().id());
} }
@PostMapping("/admin/farmers") @PostMapping("/admin/farmers")
public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) { public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) {
return catalogService.saveFarmers(mutations); return catalogService.saveFarmers(securitySupport.currentUser().id(), mutations);
} }
@PostMapping("/admin/medications") @PostMapping("/admin/medications")
public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) { public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) {
return catalogService.saveMedications(mutations); return catalogService.saveMedications(securitySupport.currentUser().id(), mutations);
} }
@PostMapping("/admin/pathogens") @PostMapping("/admin/pathogens")
public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) { public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) {
return catalogService.savePathogens(mutations); return catalogService.savePathogens(securitySupport.currentUser().id(), mutations);
} }
@PostMapping("/admin/antibiotics") @PostMapping("/admin/antibiotics")
public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) { public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
return catalogService.saveAntibiotics(mutations); return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations);
} }
@GetMapping("/portal/users") @GetMapping("/portal/users")
public List<CatalogService.UserRow> users() { public List<CatalogService.UserRow> users() {
return catalogService.listUsers(); return catalogService.listUsers(securitySupport.currentUser().id());
} }
@PostMapping("/portal/users") @PostMapping("/portal/users")
public CatalogService.UserRow saveUser(@RequestBody CatalogService.UserMutation mutation) { public CatalogService.UserRow saveUser(@RequestBody CatalogService.UserMutation mutation) {
return catalogService.createOrUpdateUser(mutation); return catalogService.createOrUpdateUser(securitySupport.currentUser().id(), mutation);
} }
@DeleteMapping("/portal/users/{id}") @DeleteMapping("/portal/users/{id}")
public void deleteUser(@PathVariable String id) { public void deleteUser(@PathVariable String id) {
catalogService.deleteUser(id); catalogService.deleteUser(securitySupport.currentUser().id(), id);
} }
@PostMapping("/portal/users/{id}/password") @PostMapping("/portal/users/{id}/password")
public void changePassword(@PathVariable String id, @RequestBody PasswordChangeRequest request) { 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) { 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.PortalService;
import de.svencarstensen.muh.service.ReportService; import de.svencarstensen.muh.service.ReportService;
import de.svencarstensen.muh.service.SampleService; import de.svencarstensen.muh.service.SampleService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -26,10 +27,12 @@ public class PortalController {
private final PortalService portalService; private final PortalService portalService;
private final ReportService reportService; 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.portalService = portalService;
this.reportService = reportService; this.reportService = reportService;
this.securitySupport = securitySupport;
} }
@GetMapping("/snapshot") @GetMapping("/snapshot")
@@ -40,27 +43,41 @@ public class PortalController {
@RequestParam(required = false) Long sampleNumber, @RequestParam(required = false) Long sampleNumber,
@RequestParam(required = false) LocalDate date @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") @GetMapping("/reports")
public List<ReportService.ReportCandidate> 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") @PostMapping("/reports/send")
public ReportService.DispatchResult send(@RequestBody ReportDispatchRequest request) { public ReportService.DispatchResult send(@RequestBody ReportDispatchRequest request) {
return reportService.sendReports(request.sampleIds()); return reportService.sendReports(securitySupport.currentUser().id(), request.sampleIds());
} }
@PatchMapping("/reports/{sampleId}/block") @PatchMapping("/reports/{sampleId}/block")
public SampleService.SampleDetail block(@PathVariable String sampleId, @RequestBody BlockRequest request) { 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") @GetMapping("/reports/{sampleId}/pdf")
public ResponseEntity<byte[]> pdf(@PathVariable String sampleId) { public ResponseEntity<byte[]> pdf(@PathVariable String sampleId) {
byte[] pdf = reportService.reportPdf(sampleId); byte[] pdf = reportService.reportPdf(securitySupport.currentUser().id(), sampleId);
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF)) .contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF))
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()

View File

@@ -1,5 +1,7 @@
package de.svencarstensen.muh.web; package de.svencarstensen.muh.web;
import de.svencarstensen.muh.security.AuthenticatedUser;
import de.svencarstensen.muh.security.SecuritySupport;
import de.svencarstensen.muh.service.SampleService; import de.svencarstensen.muh.service.SampleService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -14,53 +16,76 @@ import org.springframework.web.bind.annotation.RestController;
public class SampleController { public class SampleController {
private final SampleService sampleService; private final SampleService sampleService;
private final SecuritySupport securitySupport;
public SampleController(SampleService sampleService) { public SampleController(SampleService sampleService, SecuritySupport securitySupport) {
this.sampleService = sampleService; this.sampleService = sampleService;
this.securitySupport = securitySupport;
} }
@GetMapping("/dashboard") @GetMapping("/dashboard")
public SampleService.DashboardOverview dashboardOverview() { public SampleService.DashboardOverview dashboardOverview() {
return sampleService.dashboardOverview(); return sampleService.dashboardOverview(securitySupport.currentUser().id());
} }
@GetMapping("/dashboard/lookup/{sampleNumber}") @GetMapping("/dashboard/lookup/{sampleNumber}")
public SampleService.LookupResult lookup(@PathVariable long sampleNumber) { public SampleService.LookupResult lookup(@PathVariable long sampleNumber) {
return sampleService.lookup(sampleNumber); return sampleService.lookup(securitySupport.currentUser().id(), sampleNumber);
} }
@GetMapping("/samples/{id}") @GetMapping("/samples/{id}")
public SampleService.SampleDetail sample(@PathVariable String id) { public SampleService.SampleDetail sample(@PathVariable String id) {
return sampleService.getSample(id); return sampleService.getSample(securitySupport.currentUser().id(), id);
} }
@GetMapping("/samples/by-number/{sampleNumber}") @GetMapping("/samples/by-number/{sampleNumber}")
public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) { public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) {
return sampleService.getSampleByNumber(sampleNumber); return sampleService.getSampleByNumber(securitySupport.currentUser().id(), sampleNumber);
} }
@PostMapping("/samples") @PostMapping("/samples")
public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) { 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") @PutMapping("/samples/{id}/registration")
public SampleService.SampleDetail saveRegistration(@PathVariable String id, @RequestBody SampleService.RegistrationRequest request) { 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") @PutMapping("/samples/{id}/anamnesis")
public SampleService.SampleDetail saveAnamnesis(@PathVariable String id, @RequestBody SampleService.AnamnesisRequest request) { 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") @PutMapping("/samples/{id}/antibiogram")
public SampleService.SampleDetail saveAntibiogram(@PathVariable String id, @RequestBody SampleService.AntibiogramRequest request) { 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") @PutMapping("/samples/{id}/therapy")
public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) { 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; package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService; 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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -15,47 +19,93 @@ import java.util.List;
public class SessionController { public class SessionController {
private final CatalogService catalogService; 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.catalogService = catalogService;
this.invoiceTemplateService = invoiceTemplateService;
this.reportTemplateService = reportTemplateService;
this.securitySupport = securitySupport;
} }
@GetMapping("/users") @GetMapping("/me")
public List<CatalogService.UserOption> activeUsers() { public CatalogService.UserOption currentUser() {
return catalogService.activeCatalogSummary().users(); return catalogService.currentUser(securitySupport.currentUser().id());
} }
@PostMapping("/login") @GetMapping("/invoice-template")
public CatalogService.UserOption login(@RequestBody LoginRequest request) { public InvoiceTemplateService.InvoiceTemplateResponse currentInvoiceTemplate() {
return catalogService.loginByCode(request.code()); return invoiceTemplateService.currentTemplate(securitySupport.currentUser().id());
}
@GetMapping("/report-template")
public InvoiceTemplateService.InvoiceTemplateResponse currentReportTemplate() {
return reportTemplateService.currentTemplate(securitySupport.currentUser().id());
} }
@PostMapping("/password-login") @PostMapping("/password-login")
public CatalogService.UserOption passwordLogin(@RequestBody PasswordLoginRequest request) { public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.identifier(), request.password()); return catalogService.loginWithPassword(request.email(), request.password());
} }
@PostMapping("/register") @PostMapping("/register")
public CatalogService.UserOption register(@RequestBody RegistrationRequest request) { public CatalogService.SessionResponse register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation( return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(), request.companyName(),
request.address(), request.street(),
request.houseNumber(),
request.postalCode(),
request.city(),
request.email(), request.email(),
request.phoneNumber(),
request.password() 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( public record RegistrationRequest(
@NotBlank String companyName, @NotBlank String companyName,
@NotBlank String address, @NotBlank String street,
@NotBlank String houseNumber,
@NotBlank String postalCode,
@NotBlank String city,
@NotBlank String email, @NotBlank String email,
@NotBlank String phoneNumber,
@NotBlank String password @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

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

@@ -9,9 +9,19 @@ import AntibiogramPage from "./pages/AntibiogramPage";
import TherapyPage from "./pages/TherapyPage"; import TherapyPage from "./pages/TherapyPage";
import AdministrationPage from "./pages/AdministrationPage"; import AdministrationPage from "./pages/AdministrationPage";
import PortalPage from "./pages/PortalPage"; 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";
function ProtectedRoutes() { 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) { if (!user) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
@@ -26,7 +36,17 @@ function ProtectedRoutes() {
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} /> <Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} /> <Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} /> <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="/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 path="/portal" element={<PortalPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/home" replace />} /> <Route path="*" element={<Navigate to="/home" replace />} />
@@ -35,7 +55,10 @@ function ProtectedRoutes() {
} }
function ApplicationRouter() { function ApplicationRouter() {
const { user } = useSession(); const { user, ready } = useSession();
if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>;
}
if (!user) { if (!user) {
return ( return (
<Routes> <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,18 +1,11 @@
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSession } from "../lib/session"; 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> = { const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite", "/home": "Startseite",
"/samples/new": "Neuanlage einer Probe", "/samples/new": "Neuanlage einer Probe",
"/admin": "Verwaltung",
"/portal": "MUH-Portal", "/portal": "MUH-Portal",
"/report-template": "Bericht",
}; };
function resolvePageTitle(pathname: string) { function resolvePageTitle(pathname: string) {
@@ -28,11 +21,35 @@ function resolvePageTitle(pathname: string) {
if (pathname.includes("/registration")) { if (pathname.includes("/registration")) {
return "Probe bearbeiten"; return "Probe bearbeiten";
} }
if (pathname.startsWith("/admin/landwirte")) {
return "Die Verwaltung der Landwirte";
}
if (pathname.startsWith("/admin/benutzer")) {
return "Verwaltung | Benutzer";
}
if (pathname.startsWith("/admin/medikamente")) {
return "Die Verwaltung der Medikamente";
}
if (pathname.startsWith("/admin/erreger")) {
return "Die Verwaltung der Erreger";
}
if (pathname.startsWith("/admin/antibiogramm")) {
return "Die Verwaltung der Antibiogramme";
}
if (pathname.startsWith("/search/landwirt")) {
return "Suche | Landwirt";
}
if (pathname.startsWith("/search/probe")) {
return "Suche | Probe";
}
if (pathname.startsWith("/search/kalendar")) {
return "Suche | Kalendar";
}
return PAGE_TITLES[pathname] ?? "MUH App"; return PAGE_TITLES[pathname] ?? "MUH App";
} }
export default function AppShell() { export default function AppShell() {
const { user, setUser } = useSession(); const { user, setSession } = useSession();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,27 +61,83 @@ export default function AppShell() {
</div> </div>
<nav className="sidebar__nav"> <nav className="sidebar__nav">
{NAV_ITEMS.map((item) => ( {user?.role === "ADMIN" ? (
<NavLink <NavLink
key={item.to} to="/portal"
to={item.to}
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`} className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
> >
{item.label} Benutzerverwaltung
</NavLink> </NavLink>
))} ) : (
<>
<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> </nav>
<div className="sidebar__footer"> <div className="sidebar__footer">
<div className="user-chip user-chip--stacked"> <div className="user-chip user-chip--stacked">
<span>{user?.displayName}</span> <span>{user?.displayName}</span>
<small>{user?.code}</small> <small>{user?.email ?? user?.role}</small>
</div> </div>
<button <button
type="button" type="button"
className="ghost-button" className="ghost-button"
onClick={() => { onClick={() => {
setUser(null); setSession(null);
navigate("/"); navigate("/");
}} }}
> >
@@ -78,12 +151,6 @@ export default function AppShell() {
<div className="topbar__headline"> <div className="topbar__headline">
<h2>{resolvePageTitle(location.pathname)}</h2> <h2>{resolvePageTitle(location.pathname)}</h2>
</div> </div>
<div className="topbar__actions">
<button type="button" className="accent-button" onClick={() => navigate("/samples/new")}>
Neuanlage
</button>
</div>
</header> </header>
<main className="content-area"> <main className="content-area">

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> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); throw new ApiError(await readErrorMessage(response), response.status);
throw new Error(text || "Unbekannter API-Fehler");
} }
if (response.status === 204) { if (response.status === 204) {
return undefined as T; 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> { 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> { export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>( return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
); );
@@ -29,7 +91,10 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>( return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
); );
@@ -39,7 +104,10 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>( return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
); );
@@ -49,6 +117,7 @@ export async function apiDelete(path: string): Promise<void> {
await handleResponse<void>( await handleResponse<void>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "DELETE", method: "DELETE",
headers: authHeaders(),
}), }),
); );
} }

View File

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

View File

@@ -1 +1,2 @@
export const USER_STORAGE_KEY = "muh.current-user"; 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" | "SYSTEMIC_PAIN"
| "DRY_SEALER" | "DRY_SEALER"
| "DRY_ANTIBIOTIC"; | "DRY_ANTIBIOTIC";
export type UserRole = "APP" | "ADMIN" | "CUSTOMER"; export type UserRole = "ADMIN" | "CUSTOMER";
export interface FarmerOption { export interface FarmerOption {
businessKey: string; businessKey: string;
@@ -45,15 +45,24 @@ export interface AntibioticOption {
export interface UserOption { export interface UserOption {
id: string; id: string;
code: string; primaryUser: boolean;
displayName: string; displayName: string;
companyName: string | null; companyName: string | null;
address: string | null; address: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null; email: string | null;
portalLogin: string | null; phoneNumber: string | null;
role: UserRole; role: UserRole;
} }
export interface SessionResponse {
token: string;
user: UserOption;
}
export interface UserRow extends UserOption { export interface UserRow extends UserOption {
active: boolean; active: boolean;
updatedAt: string; updatedAt: string;

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useState } from "react";
import { apiGet, apiPost } from "../lib/api"; import { apiPost } from "../lib/api";
import { useSession } from "../lib/session"; import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types"; import type { SessionResponse } from "../lib/types";
type FeedbackState = type FeedbackState =
| { type: "error"; text: string } | { type: "error"; text: string }
@@ -9,59 +9,47 @@ type FeedbackState =
| null; | null;
export default function LoginPage() { export default function LoginPage() {
const [users, setUsers] = useState<UserOption[]>([]); const [showRegistration, setShowRegistration] = useState(false);
const [manualCode, setManualCode] = useState(""); const [email, setEmail] = useState("");
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loginInputsUnlocked, setLoginInputsUnlocked] = useState(false);
const [showLoginValidation, setShowLoginValidation] = useState(false);
const [showRegisterValidation, setShowRegisterValidation] = useState(false);
const [registration, setRegistration] = useState({ const [registration, setRegistration] = useState({
companyName: "", companyName: "",
address: "", street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "", email: "",
phoneNumber: "",
password: "", password: "",
passwordConfirmation: "",
}); });
const [loading, setLoading] = useState(true);
const [feedback, setFeedback] = useState<FeedbackState>(null); const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setUser } = useSession(); const { setSession } = useSession();
async function loadUsers() { function unlockLoginInputs() {
setLoading(true); setLoginInputsUnlocked(true);
setFeedback(null);
try {
const response = await apiGet<UserOption[]>("/session/users");
setUsers(response);
} catch (loadError) {
setFeedback({ type: "error", text: (loadError as Error).message });
setUsers([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadUsers();
}, []);
async function handleCodeLogin(code: string) {
if (!code.trim()) {
setFeedback({ type: "error", text: "Bitte ein Benutzerkuerzel eingeben oder auswaehlen." });
return;
}
try {
const response = await apiPost<UserOption>("/session/login", { code });
setUser(response);
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
} }
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) { async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setShowLoginValidation(true);
if (!email.trim() || !password.trim()) {
setFeedback({
type: "error",
text: "Bitte E-Mail und Passwort eingeben.",
});
return;
}
try { try {
const response = await apiPost<UserOption>("/session/password-login", { setFeedback(null);
identifier, const response = await apiPost<SessionResponse>("/session/password-login", {
email: email.trim(),
password, password,
}); });
setUser(response); setSession(response);
} catch (loginError) { } catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message }); setFeedback({ type: "error", text: (loginError as Error).message });
} }
@@ -69,13 +57,37 @@ export default function LoginPage() {
async function handleRegister(event: FormEvent<HTMLFormElement>) { async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); 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 { 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({ setFeedback({
type: "success", type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`, text: `Registrierung erfolgreich. Willkommen ${response.user.companyName ?? response.user.displayName}.`,
}); });
setUser(response); setSession(response);
} catch (registrationError) { } catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message }); setFeedback({ type: "error", text: (registrationError as Error).message });
} }
@@ -88,7 +100,7 @@ export default function LoginPage() {
<p className="eyebrow">MUH-App</p> <p className="eyebrow">MUH-App</p>
<h1>Moderne Steuerung fuer Milchproben und Therapien.</h1> <h1>Moderne Steuerung fuer Milchproben und Therapien.</h1>
<p className="hero-text"> <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. fuer Verwaltung, Berichtsdruck und Versandstatus.
</p> </p>
</div> </div>
@@ -98,8 +110,7 @@ export default function LoginPage() {
<p className="eyebrow">Zugang</p> <p className="eyebrow">Zugang</p>
<h2>Anmelden oder registrieren</h2> <h2>Anmelden oder registrieren</h2>
<p className="muted-text"> <p className="muted-text">
Weiterhin moeglich: Direktanmeldung per Benutzerkuerzel. Neu: Login mit Anmeldung per E-Mail mit Passwort sowie direkte Kundenregistrierung.
E-Mail/Benutzername und Passwort.
</p> </p>
{feedback ? ( {feedback ? (
@@ -108,146 +119,194 @@ export default function LoginPage() {
</div> </div>
) : null} ) : null}
<div className="login-panel__section"> <div className="auth-grid">
<div className="section-card__header"> {!showRegistration ? (
<div> <form
<p className="eyebrow">Schnelllogin</p> className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`}
<h3>Benutzerkuerzel</h3> onSubmit={handlePasswordLogin}
</div> autoComplete="off"
<button type="button" className="secondary-button" onClick={() => void loadUsers()}> >
Neu laden <label className="field field--required">
</button> <span>E-Mail</span>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen ...</div>
) : users.length ? (
<div className="user-grid">
{users.map((user) => (
<button
key={user.id}
type="button"
className="user-card"
onClick={() => void handleCodeLogin(user.code)}
>
<span className="user-card__code">{user.code}</span>
<strong>{user.displayName}</strong>
<small>
{user.role === "ADMIN"
? "Admin"
: user.role === "CUSTOMER"
? "Kunde"
: "App"}
</small>
</button>
))}
</div>
) : (
<div className="page-stack">
<div className="empty-state">
Es wurden keine aktiven Benutzer geladen. Das Kuersel kann trotzdem direkt
eingegeben werden.
</div>
<label className="field">
<span>Benutzerkuerzel</span>
<input <input
value={manualCode} type="email"
onChange={(event) => setManualCode(event.target.value.toUpperCase())} name="login-email"
placeholder="z. B. SV" 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> </label>
<div className="page-actions"> <div className="page-actions">
<button type="submit" className="accent-button">
Mit Passwort anmelden
</button>
<button <button
type="button" type="button"
className="accent-button" className="secondary-button"
onClick={() => void handleCodeLogin(manualCode)} onClick={() => {
setFeedback(null);
setShowRegisterValidation(false);
setShowRegistration(true);
}}
> >
Mit Kuerzel anmelden Registrieren
</button> </button>
</div> </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>
<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> </div>
</section> </section>
</div> </div>

View File

@@ -1,55 +1,25 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; 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"; 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() { export default function PortalPage() {
const { user } = useSession();
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null); 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 [selectedReports, setSelectedReports] = useState<string[]>([]);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [userForm, setUserForm] = useState({ const [userForm, setUserForm] = useState({
code: "",
displayName: "", displayName: "",
email: "", email: "",
portalLogin: "",
password: "", password: "",
role: "APP" as UserRole, role: "CUSTOMER" as UserRole,
}); });
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({}); const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
const [showUserValidation, setShowUserValidation] = useState(false);
const isAdmin = user?.role === "ADMIN";
async function loadSnapshot() { async function loadSnapshot() {
const params = new URLSearchParams(); const response = await apiGet<PortalSnapshot>("/portal/snapshot");
if (selectedFarmer) {
params.set("farmerBusinessKey", selectedFarmer);
}
if (farmerQuery) {
params.set("farmerQuery", farmerQuery);
}
if (cowQuery) {
params.set("cowQuery", cowQuery);
}
if (sampleNumberQuery) {
params.set("sampleNumber", sampleNumberQuery);
}
if (dateQuery) {
params.set("date", dateQuery);
}
const response = await apiGet<PortalSnapshot>(`/portal/snapshot?${params.toString()}`);
setSnapshot(response); setSnapshot(response);
setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId)); 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() { async function handleDispatchReports() {
try { try {
const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", { const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", {
@@ -97,19 +57,23 @@ export default function PortalPage() {
async function handleCreateUser(event: FormEvent<HTMLFormElement>) { async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setShowUserValidation(true);
if (!isAdmin) {
setMessage("Nur Administratoren koennen Benutzer anlegen.");
return;
}
try { try {
await apiPost("/portal/users", { await apiPost("/portal/users", {
...userForm, ...userForm,
active: true, active: true,
}); });
setUserForm({ setUserForm({
code: "",
displayName: "", displayName: "",
email: "", email: "",
portalLogin: "",
password: "", password: "",
role: "APP", role: "CUSTOMER",
}); });
setShowUserValidation(false);
setMessage("Benutzer gespeichert."); setMessage("Benutzer gespeichert.");
await loadSnapshot(); await loadSnapshot();
} catch (userError) { } 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) { if (!snapshot) {
return <div className="empty-state">Portal wird geladen ...</div>; return <div className="empty-state">Portal wird geladen ...</div>;
} }
@@ -203,227 +158,97 @@ export default function PortalPage() {
</div> </div>
</article> </article>
<article className="section-card"> {isAdmin ? (
<p className="eyebrow">Benutzerverwaltung</p> <article className="section-card">
<form className="field-grid" onSubmit={handleCreateUser}> <p className="eyebrow">Benutzerverwaltung</p>
<label className="field"> <form className={`field-grid ${showUserValidation ? "show-validation" : ""}`} onSubmit={handleCreateUser}>
<span>Kuerzel</span> <label className="field field--required">
<input <span>Name</span>
value={userForm.code} <input
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))} value={userForm.displayName}
/> onChange={(event) =>
</label> setUserForm((current) => ({ ...current, displayName: event.target.value }))
<label className="field"> }
<span>Name</span> required
<input />
value={userForm.displayName} </label>
onChange={(event) => <label className="field">
setUserForm((current) => ({ ...current, displayName: event.target.value })) <span>E-Mail</span>
} <input
/> type="email"
</label> value={userForm.email}
<label className="field"> onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
<span>Login</span> />
<input </label>
value={userForm.portalLogin} <label className="field">
onChange={(event) => <span>Passwort</span>
setUserForm((current) => ({ ...current, portalLogin: event.target.value })) <input
} value={userForm.password}
/> onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
</label> type="password"
<label className="field"> />
<span>E-Mail</span> </label>
<input <label className="field">
type="email" <span>Rolle</span>
value={userForm.email} <select
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))} value={userForm.role}
/> onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))}
</label> >
<label className="field"> <option value="CUSTOMER">CUSTOMER</option>
<span>Passwort</span> <option value="ADMIN">ADMIN</option>
<input </select>
value={userForm.password} </label>
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))} <div className="page-actions">
type="password" <button type="submit" className="accent-button">
/> Benutzer anlegen
</label> </button>
<label className="field"> </div>
<span>Rolle</span> </form>
<select
value={userForm.role}
onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))}
>
<option value="APP">APP</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div>
</form>
<div className="table-shell"> <div className="table-shell">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>Kuerzel</th> <th>Name</th>
<th>Name</th> <th>E-Mail</th>
<th>E-Mail</th> <th>Rolle</th>
<th>Login</th> <th>Passwort</th>
<th>Rolle</th> <th />
<th>Passwort</th>
<th />
</tr>
</thead>
<tbody>
{snapshot.users.map((user) => (
<tr key={user.id}>
<td>{user.code}</td>
<td>{user.displayName}</td>
<td>{user.email ?? "-"}</td>
<td>{user.portalLogin ?? "-"}</td>
<td>{user.role}</td>
<td>
<input
type="password"
value={passwordDrafts[user.id] ?? ""}
onChange={(event) =>
setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
}
placeholder="Neues Passwort"
/>
</td>
<td className="table-actions">
<button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
Speichern
</button>
<button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
Loeschen
</button>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {snapshot.users.map((user) => (
</div> <tr key={user.id}>
</article> <td>{user.displayName}</td>
</section> <td>{user.email ?? "-"}</td>
<td>{user.role}</td>
<section className="section-card"> <td>
<form className="field-grid" onSubmit={handleSearch}> <input
<label className="field"> type="password"
<span>Landwirt suchen</span> value={passwordDrafts[user.id] ?? ""}
<input value={farmerQuery} onChange={(event) => setFarmerQuery(event.target.value)} /> onChange={(event) =>
</label> setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
<label className="field"> }
<span>Gefundener Landwirt</span> placeholder="Neues Passwort"
<select value={selectedFarmer} onChange={(event) => setSelectedFarmer(event.target.value)}> />
<option value="">alle / noch keiner</option> </td>
{snapshot.farmers.map((farmer) => ( <td className="table-actions">
<option key={farmer.businessKey} value={farmer.businessKey}> <button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
{farmer.name} Speichern
</option>
))}
</select>
</label>
<label className="field">
<span>Kuh</span>
<input value={cowQuery} onChange={(event) => setCowQuery(event.target.value)} />
</label>
<label className="field">
<span>Probe-Nr.</span>
<input value={sampleNumberQuery} onChange={(event) => setSampleNumberQuery(event.target.value)} />
</label>
<label className="field">
<span>Datum</span>
<input type="date" value={dateQuery} onChange={(event) => setDateQuery(event.target.value)} />
</label>
<div className="page-actions page-actions--align-end">
<button type="submit" className="accent-button">
Suche starten
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
setSelectedFarmer("");
setFarmerQuery("");
setCowQuery("");
setSampleNumberQuery("");
setDateQuery("");
void handleSearch();
}}
>
Zuruecksetzen
</button>
</div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Suchergebnis</p>
<h3>Gefundene Milchproben</h3>
</div>
</div>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Probe</th>
<th>Anlage</th>
<th>Landwirt</th>
<th>Kuh</th>
<th>Typ</th>
<th>Interne Bemerkung</th>
<th>PDF</th>
<th>Versand</th>
</tr>
</thead>
<tbody>
{snapshot.samples.map((sample) => (
<tr key={sample.sampleId}>
<td>{sample.sampleNumber}</td>
<td>{formatDate(sample.createdAt)}</td>
<td>{sample.farmerName}</td>
<td>{sample.cowNumber}{sample.cowName ? ` / ${sample.cowName}` : ""}</td>
<td>{sample.sampleKindLabel === "DRY_OFF" ? "Trockensteller" : "Milchprobe"}</td>
<td>{sample.internalNote ?? "-"}</td>
<td>
{sample.completed ? (
<a className="table-link" href={pdfUrl(sample.sampleId)} target="_blank" rel="noreferrer">
PDF
</a>
) : (
<span className="muted-text">-</span>
)}
</td>
<td>
<div className="table-actions">
<span className={`status-pill ${sample.reportSent ? "status-pill--completed" : "status-pill--therapy"}`}>
{sample.reportSent ? "versendet" : "offen"}
</span>
{sample.completed ? (
<button
type="button"
className="table-link"
onClick={() => void handleToggleBlocked(sample.sampleId, !sample.reportBlocked)}
>
{sample.reportBlocked ? "freigeben" : "blockieren"}
</button> </button>
) : null} <button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
</div> Loeschen
</td> </button>
</tr> </td>
))} </tr>
</tbody> ))}
</table> </tbody>
</div> </table>
</div>
</article>
) : null}
</section> </section>
</div> </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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@@ -79,6 +80,7 @@ export default function SampleRegistrationPage() {
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setShowValidation(true);
if (!user) { if (!user) {
return; return;
} }
@@ -97,7 +99,7 @@ export default function SampleRegistrationPage() {
sampleKind, sampleKind,
samplingMode, samplingMode,
flaggedQuarters, flaggedQuarters,
userCode: user.code, userCode: user.displayName,
userDisplayName: user.displayName, userDisplayName: user.displayName,
}; };
@@ -118,14 +120,14 @@ export default function SampleRegistrationPage() {
} }
return ( return (
<form className="page-stack" onSubmit={handleSubmit}> <form className={`page-stack ${showValidation ? "show-validation" : ""}`} onSubmit={handleSubmit}>
<section className="section-card section-card--hero"> <section className="section-card section-card--hero">
<div> <div>
<p className="eyebrow">Neuanlage</p> <p className="eyebrow">Neuanlage</p>
<h3>Probe {sampleNumber ?? "..."}</h3> <h3>Probe {sampleNumber ?? "..."}</h3>
<p className="muted-text"> <p className="muted-text">
Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich ueber den Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich ueber den
Schalter TS markieren. Schalter Trockenstellerprobe markieren.
</p> </p>
</div> </div>
@@ -138,16 +140,17 @@ export default function SampleRegistrationPage() {
{message ? <div className="alert alert--error">{message}</div> : null} {message ? <div className="alert alert--error">{message}</div> : null}
</section> </section>
<section className="form-grid"> <section className="form-grid form-grid--stacked">
<article className="section-card"> <article className="section-card">
<p className="eyebrow">Stammdaten</p> <p className="eyebrow">Stammdaten</p>
<div className="field-grid"> <div className="field-grid field-grid--stacked">
<label className="field"> <label className="field field--required">
<span>Landwirt</span> <span>Landwirt</span>
<select <select
value={farmerBusinessKey} value={farmerBusinessKey}
onChange={(event) => setFarmerBusinessKey(event.target.value)} onChange={(event) => setFarmerBusinessKey(event.target.value)}
disabled={!editable} disabled={!editable}
required
> >
{catalogs?.farmers.map((farmer) => ( {catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}> <option key={farmer.businessKey} value={farmer.businessKey}>
@@ -157,12 +160,13 @@ export default function SampleRegistrationPage() {
</select> </select>
</label> </label>
<label className="field"> <label className="field field--required">
<span>Kuh-Nummer</span> <span>Kuh-Nummer</span>
<input <input
value={cowNumber} value={cowNumber}
onChange={(event) => setCowNumber(event.target.value)} onChange={(event) => setCowNumber(event.target.value)}
disabled={!editable} disabled={!editable}
required
/> />
</label> </label>
@@ -194,7 +198,7 @@ export default function SampleRegistrationPage() {
onClick={() => setSampleKind("DRY_OFF")} onClick={() => setSampleKind("DRY_OFF")}
disabled={!editable} disabled={!editable}
> >
TS Trockenstellerprobe
</button> </button>
</div> </div>
@@ -247,7 +251,7 @@ export default function SampleRegistrationPage() {
disabled={!editable} disabled={!editable}
> >
<span>{quarter.label}</span> <span>{quarter.label}</span>
<strong>{flaggedQuarters.includes(quarter.key) ? "" : "OK"}</strong> <strong>{flaggedQuarters.includes(quarter.key) ? "Auffällig" : "OK"}</strong>
</button> </button>
))} ))}
</div> </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,479 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption, UserRole, UserRow } from "../lib/types";
type UserDraft = UserRow & {
password: string;
passwordRepeat: string;
};
function toDraft(user: UserRow): UserDraft {
return {
...user,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
};
}
function emptyUser(): UserDraft {
return {
id: "",
primaryUser: false,
displayName: "",
companyName: "",
address: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
password: "",
passwordRepeat: "",
active: true,
role: "CUSTOMER",
updatedAt: new Date().toISOString(),
};
}
function toDraftFromSession(user: UserOption): UserDraft {
return {
id: user.id,
primaryUser: user.primaryUser,
displayName: user.displayName,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
active: true,
role: user.role,
updatedAt: new Date().toISOString(),
};
}
function isAccessDenied(error: unknown): boolean {
return error instanceof Error && error.message.trim().toLowerCase() === "access denied";
}
function toMutation(user: UserDraft) {
return {
id: user.id || null,
displayName: user.displayName,
companyName: user.companyName || null,
address: user.address || null,
street: user.street || null,
houseNumber: user.houseNumber || null,
postalCode: user.postalCode || null,
city: user.city || null,
email: user.email || null,
phoneNumber: user.phoneNumber || null,
password: user.password || null,
active: user.active,
role: user.role,
};
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<UserDraft[]>([]);
const [newUser, setNewUser] = useState<UserDraft>(emptyUser());
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const isAdmin = user?.role === "ADMIN";
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
setUsers(response.map(toDraft));
setMessage(null);
} catch (error) {
if (!isAdmin && user?.primaryUser && isAccessDenied(error)) {
setUsers([toDraftFromSession(user)]);
setMessage(null);
return;
}
throw error;
}
}
useEffect(() => {
void loadUsers().catch((error) => setMessage((error as Error).message));
}, []);
const primaryUser = useMemo(
() => users.find((entry) => entry.primaryUser) ?? null,
[users],
);
const secondaryUsers = useMemo(
() => users.filter((entry) => !entry.primaryUser),
[users],
);
function updateExistingUser(userId: string, patch: Partial<UserDraft>) {
setUsers((current) =>
current.map((entry) => (entry.id === userId ? { ...entry, ...patch } : entry)),
);
}
async function saveUser(draft: UserDraft) {
setShowValidation(true);
if (!draft.displayName.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
try {
const saved = await apiPost<UserRow>("/portal/users", toMutation(draft));
setUsers((current) =>
current.map((entry) => (entry.id === draft.id ? toDraft(saved) : entry)),
);
setMessage(draft.primaryUser ? "Stammdaten gespeichert." : "Benutzer gespeichert.");
} catch (error) {
setMessage((error as Error).message);
}
}
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!newUser.displayName.trim() || !(newUser.email ?? "").trim() || !newUser.password.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
if (newUser.password !== newUser.passwordRepeat) {
setMessage("Die Passwoerter stimmen nicht ueberein.");
return;
}
try {
await apiPost<UserRow>("/portal/users", toMutation(newUser));
setNewUser(emptyUser());
setMessage("Benutzer angelegt.");
await loadUsers();
} catch (error) {
setMessage((error as Error).message);
}
}
async function removeUser(userId: string) {
try {
await apiDelete(`/portal/users/${userId}`);
setUsers((current) => current.filter((entry) => entry.id !== userId));
setMessage("Benutzer geloescht.");
void loadUsers().catch(() => undefined);
} catch (error) {
setMessage((error as Error).message);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Benutzer und Stammdaten</h3>
<p className="muted-text">
Hier pflegen Sie den Hauptbenutzer Ihres Kontos und legen weitere Benutzer an.
</p>
</div>
{message ? (
<div
className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
) : null}
</section>
{primaryUser ? (
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Hauptbenutzer</p>
<h3>Stammdaten bearbeiten</h3>
</div>
<div className="info-chip">{primaryUser.email ?? primaryUser.displayName}</div>
</div>
<div className={`field-grid ${showValidation ? "show-validation" : ""}`}>
<label className="field field--required">
<span>Name</span>
<input
required
value={primaryUser.displayName}
onChange={(event) =>
updateExistingUser(primaryUser.id, { displayName: event.target.value })
}
/>
</label>
<label className="field">
<span>Firmenname</span>
<input
value={primaryUser.companyName ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { companyName: event.target.value })
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={primaryUser.email ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { email: event.target.value })}
/>
</label>
<label className="field">
<span>Strasse</span>
<input
value={primaryUser.street ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { street: event.target.value })}
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
value={primaryUser.houseNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { houseNumber: event.target.value })
}
/>
</label>
<label className="field">
<span>PLZ</span>
<input
value={primaryUser.postalCode ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { postalCode: event.target.value })
}
/>
</label>
<label className="field">
<span>Ort</span>
<input
value={primaryUser.city ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { city: event.target.value })}
/>
</label>
<label className="field">
<span>Telefonnummer</span>
<input
value={primaryUser.phoneNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { phoneNumber: event.target.value })
}
/>
</label>
<label className="field field--wide">
<span>Neues Passwort</span>
<input
type="password"
value={primaryUser.password}
onChange={(event) => updateExistingUser(primaryUser.id, { password: event.target.value })}
/>
</label>
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void saveUser(primaryUser)}>
Stammdaten speichern
</button>
</div>
</section>
) : null}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzer</p>
<h3>Benutzer anlegen</h3>
</div>
</div>
<form
className={`field-grid ${showValidation ? "show-validation" : ""}`}
onSubmit={createUser}
autoComplete="off"
>
<label className="field field--required">
<span>Name</span>
<input
required
value={newUser.displayName}
onChange={(event) =>
setNewUser((current) => ({ ...current, displayName: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>E-Mail</span>
<input
required
type="email"
value={newUser.email ?? ""}
autoComplete="off"
onChange={(event) => setNewUser((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<label className="field field--required">
<span>Passwort</span>
<input
required
type="password"
autoComplete="new-password"
value={newUser.password}
onChange={(event) =>
setNewUser((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>Passwort wiederholen</span>
<input
required
type="password"
autoComplete="new-password"
className={
showValidation && newUser.password !== newUser.passwordRepeat ? "is-invalid" : ""
}
value={newUser.passwordRepeat}
onChange={(event) =>
setNewUser((current) => ({ ...current, passwordRepeat: event.target.value }))
}
/>
</label>
{isAdmin ? (
<label className="field">
<span>Rolle</span>
<select
value={newUser.role}
onChange={(event) =>
setNewUser((current) => ({
...current,
role: event.target.value as UserRole,
}))
}
>
<option value="CUSTOMER">CUSTOMER</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
) : null}
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzerliste</p>
<h3>Bereits angelegte Benutzer</h3>
</div>
</div>
{secondaryUsers.length === 0 ? (
<div className="empty-state">Noch keine weiteren Benutzer vorhanden.</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th className="required-label">Name</th>
<th>E-Mail</th>
<th>Passwort</th>
<th>Aktiv</th>
<th />
</tr>
</thead>
<tbody>
{secondaryUsers.map((entry) => (
<tr key={entry.id}>
<td>
<input
required
className={showValidation && !entry.displayName.trim() ? "is-invalid" : ""}
value={entry.displayName}
onChange={(event) =>
updateExistingUser(entry.id, { displayName: event.target.value })
}
/>
</td>
<td>
<input
type="email"
value={entry.email ?? ""}
onChange={(event) => updateExistingUser(entry.id, { email: event.target.value })}
/>
</td>
<td>
<input
type="password"
value={entry.password}
onChange={(event) =>
updateExistingUser(entry.id, { password: event.target.value })
}
placeholder="Neues Passwort"
/>
</td>
<td>
<select
value={entry.active ? "true" : "false"}
onChange={(event) =>
updateExistingUser(entry.id, { active: event.target.value === "true" })
}
>
<option value="true">aktiv</option>
<option value="false">inaktiv</option>
</select>
</td>
<td className="table-actions">
<button
type="button"
className="table-link"
onClick={() => void saveUser(entry)}
>
Speichern
</button>
<button
type="button"
className="table-link table-link--danger"
onClick={() => void removeUser(entry.id)}
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View File

@@ -57,16 +57,21 @@ a {
display: grid; display: grid;
grid-template-columns: minmax(228px, 280px) minmax(0, 1fr); grid-template-columns: minmax(228px, 280px) minmax(0, 1fr);
min-height: 100vh; min-height: 100vh;
align-items: start;
} }
.sidebar { .sidebar {
position: sticky;
top: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
padding: 28px 24px; padding: 28px 24px;
min-height: 100vh;
height: 100vh;
background: rgba(23, 34, 41, 0.92); background: rgba(23, 34, 41, 0.92);
color: #f8f3ed; color: #f8f3ed;
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
overflow: hidden;
} }
.sidebar__brand { .sidebar__brand {
@@ -89,8 +94,8 @@ a {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
min-height: 108px; min-height: 54px;
padding: 16px 24px; padding: 8px 24px;
border-radius: 28px; border-radius: 28px;
background: linear-gradient(135deg, #1d9485, #0f5b53); background: linear-gradient(135deg, #1d9485, #0f5b53);
font-weight: 700; font-weight: 700;
@@ -100,7 +105,9 @@ a {
.sidebar__nav { .sidebar__nav {
display: grid; display: grid;
gap: 10px; gap: 10px;
margin: 32px 0 auto; margin: 32px 0 0;
flex: 1 1 auto;
align-content: start;
} }
.nav-link { .nav-link {
@@ -111,6 +118,50 @@ a {
transition: background 160ms ease, color 160ms ease, transform 160ms ease; 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:hover,
.nav-link.is-active { .nav-link.is-active {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
@@ -118,9 +169,18 @@ a {
transform: translateX(4px); transform: translateX(4px);
} }
.nav-sublink:hover,
.nav-sublink.is-active {
background: rgba(255, 255, 255, 0.08);
color: #fff8f0;
transform: translateX(4px);
}
.sidebar__footer { .sidebar__footer {
display: grid; display: grid;
gap: 12px; gap: 12px;
margin-top: auto;
padding-top: 24px;
} }
.user-chip { .user-chip {
@@ -252,6 +312,7 @@ a {
.portal-grid { .portal-grid {
display: grid; display: grid;
gap: 20px; gap: 20px;
align-items: stretch;
} }
.metrics-grid { .metrics-grid {
@@ -263,6 +324,14 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 { .metric-card {
padding: 22px 24px; padding: 22px 24px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -302,16 +371,40 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.field-grid--stacked {
grid-template-columns: 1fr;
}
.field { .field {
display: grid; display: grid;
gap: 8px; gap: 8px;
} }
.field--wide {
grid-column: 1 / -1;
}
.field--spaced {
margin-top: 28px;
}
.field span { .field span {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); 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 input,
.field select, .field select,
.field textarea, .field textarea,
@@ -330,6 +423,21 @@ a {
resize: vertical; 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, .choice-row,
.tab-row, .tab-row,
.page-actions, .page-actions,
@@ -364,6 +472,10 @@ a {
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32); box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32);
} }
.tab-chip.is-invalid {
color: var(--danger);
}
.section-card__header { .section-card__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -393,13 +505,19 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 { .user-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 18px; margin-top: 18px;
} }
.auth-grid { .auth-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: 1fr;
margin-top: 18px; margin-top: 18px;
} }
@@ -551,6 +669,10 @@ a {
color: var(--muted); color: var(--muted);
} }
.info-panel--spaced {
margin-top: 28px;
}
.alert { .alert {
border: 1px solid transparent; border: 1px solid transparent;
} }
@@ -582,10 +704,10 @@ a {
.login-hero { .login-hero {
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1fr;
gap: 28px; gap: 28px;
align-items: stretch; align-items: stretch;
width: min(1240px, 100%); width: min(960px, 100%);
} }
.login-hero__copy { .login-hero__copy {
@@ -645,7 +767,402 @@ a {
justify-content: flex-end; 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;
}
.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) { @media (max-width: 1200px) {
.invoice-template-page,
.invoice-template-page__card {
height: auto;
overflow: visible;
}
.app-shell { .app-shell {
grid-template-columns: 240px minmax(0, 1fr); grid-template-columns: 240px minmax(0, 1fr);
} }
@@ -657,10 +1174,25 @@ a {
.portal-grid, .portal-grid,
.form-grid, .form-grid,
.field-grid, .field-grid,
.metrics-grid { .metrics-grid,
.invoice-template {
grid-template-columns: 1fr; 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 { .quarter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -690,4 +1222,27 @@ a {
.quarter-grid { .quarter-grid {
grid-template-columns: 1fr; 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 { interface ImportMetaEnv {
readonly DEV: boolean;
readonly VITE_API_URL?: string; readonly VITE_API_URL?: string;
} }