Compare commits

...

5 Commits

Author SHA1 Message Date
3755f4c414 refactor: update farmer table layout and styling 2026-03-18 22:04:13 +01:00
e7a18cd339 feat: extend farmer data model with complete address fields and customer number
Backend:
- Add customerNumber, companyName, contactPerson, street, houseNumber,
  postalCode, city, phoneNumber to Farmer domain model
- Update FarmerRow and FarmerMutation records with new fields
- Update repositories, services and controllers for new farmer structure
- Fix CORS configuration to allow credentials
- Remove unused authorizationService and imports
- Update data migration for new farmer schema

Frontend:
- Update FarmerRow and FarmerOption interfaces
- Extend AdministrationPage with new farmer form fields
- Update SampleRegistrationPage and SearchFarmerPage for new structure
2026-03-18 21:13:29 +01:00
f9b83a166d feat: isolate catalog data per primary user (accountId)
- Add accountId to Farmer, MedicationCatalogItem, PathogenCatalogItem, AntibioticCatalogItem
- Create new /api/catalog endpoints for CUSTOMER role only
- Remove DemoDataInitializer (no more demo data generation)
- Add DefaultUserInitializer for admin user creation only
- Update repositories with accountId-based query methods
- Update CatalogService with accountId isolation and role-based access
- Update SecurityConfig: /api/catalog/** for CUSTOMER, /api/admin/** for ADMIN
- Update frontend AdministrationPage to use new /api/catalog endpoints
- Migrate existing data without accountId to first admin user
2026-03-18 20:40:10 +01:00
09e6d07c2d chore: remove debug logging from authentication filter 2026-03-18 20:13:08 +01:00
f9e370afe2 feat: add customer number generation and invoice creation
Backend:
- Add customerNumber field to AppUser with unique index
- Add customer number generation starting at K1000
- Add InvoiceService for creating invoices with PDF data
- Add InvoiceController with endpoints for listing customers and generating invoice data
- Update CatalogService to assign customer numbers to new users
- Update SampleService to preserve customerNumber on updates
- Fix SecurityConfig to enable method security and admin role checks

Frontend:
- Update UserOption/UserRow types to include customerNumber
- Add customer selection dialog in InvoiceManagementPage
- Add PDF generation and preview for invoices
- Fix encodePdfText function for proper PDF encoding
- Fix LocalStorage key for auth token
2026-03-18 20:10:38 +01:00
30 changed files with 2465 additions and 528 deletions

View File

@@ -107,6 +107,7 @@ Kundenregistrierung:
- `cd frontend && npm run build` - `cd frontend && npm run build`
## Docker ## Docker
docker build -t muh:0.9.1 .
docker buildx build --platform linux/amd64 -t gitea.appcreation.de/sven/muh:0.8.0 --push . docker buildx build --platform linux/amd64 -t gitea.appcreation.de/sven/muh:0.8.0 --push .
docker run -d --name muh --network br0 --ip 192.168.180.26 --restart unless-stopped -e MUH_MONGODB_URL=mongodb://192.168.180.25:27017/muh -e MUH_TOKEN_SECRET=local-dev-muh-token-secret-2026-03-13 -e MUH_TOKEN_VALIDITY_HOURS=12 -e MUH_ALLOWED_ORIGINS=https://muh.appcreation.de gitea.appcreation.de/sven/muh:0.8.0 docker run -d --name muh --network br0 --ip 192.168.180.26 --restart unless-stopped -e MUH_MONGODB_URL=mongodb://192.168.180.25:27017/muh -e MUH_TOKEN_SECRET=local-dev-muh-token-secret-2026-03-13 -e MUH_TOKEN_VALIDITY_HOURS=12 -e MUH_ALLOWED_ORIGINS=https://muh.appcreation.de gitea.appcreation.de/sven/muh:0.8.0

View File

@@ -18,7 +18,8 @@ public class CorsConfig {
configuration.setAllowedOrigins(allowedOrigins); configuration.setAllowedOrigins(allowedOrigins);
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowCredentials(false); configuration.setAllowCredentials(true);
configuration.setExposedHeaders(List.of("Authorization"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration); source.registerCorsConfiguration("/api/**", configuration);

View File

@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
@Document("antibiotics") @Document("antibiotics")
public record AntibioticCatalogItem( public record AntibioticCatalogItem(
@Id String id, @Id String id,
String accountId,
String businessKey, String businessKey,
String code, String code,
String name, String name,

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.domain; package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -27,6 +28,7 @@ public record AppUser(
boolean active, boolean active,
UserRole role, UserRole role,
Long nextSampleNumber, Long nextSampleNumber,
@Indexed(unique = true) String customerNumber,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {

View File

@@ -8,9 +8,17 @@ import java.time.LocalDateTime;
@Document("farmers") @Document("farmers")
public record Farmer( public record Farmer(
@Id String id, @Id String id,
String accountId,
String businessKey, String businessKey,
String name, String customerNumber,
String companyName,
String contactPerson,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber,
boolean active, boolean active,
String supersedesId, String supersedesId,
LocalDateTime createdAt, LocalDateTime createdAt,

View File

@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
@Document("medications") @Document("medications")
public record MedicationCatalogItem( public record MedicationCatalogItem(
@Id String id, @Id String id,
String accountId,
String businessKey, String businessKey,
String name, String name,
MedicationCategory category, MedicationCategory category,

View File

@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
@Document("pathogens") @Document("pathogens")
public record PathogenCatalogItem( public record PathogenCatalogItem(
@Id String id, @Id String id,
String accountId,
String businessKey, String businessKey,
String code, String code,
String name, String name,

View File

@@ -7,4 +7,8 @@ import java.util.List;
public interface AntibioticCatalogRepository extends MongoRepository<AntibioticCatalogItem, String> { public interface AntibioticCatalogRepository extends MongoRepository<AntibioticCatalogItem, String> {
List<AntibioticCatalogItem> findByActiveTrueOrderByNameAsc(); List<AntibioticCatalogItem> findByActiveTrueOrderByNameAsc();
List<AntibioticCatalogItem> findByAccountIdOrderByNameAsc(String accountId);
List<AntibioticCatalogItem> findByAccountIdAndActiveTrueOrderByNameAsc(String accountId);
} }

View File

@@ -2,6 +2,7 @@ package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.AppUser; import de.svencarstensen.muh.domain.AppUser;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -12,4 +13,9 @@ public interface AppUserRepository extends MongoRepository<AppUser, String> {
List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId); List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
Optional<AppUser> findByEmailIgnoreCase(String email); Optional<AppUser> findByEmailIgnoreCase(String email);
Optional<AppUser> findByCustomerNumber(String customerNumber);
@Query(value = "{ 'customerNumber': { $exists: true, $ne: null } }", sort = "{ 'customerNumber': -1 }")
List<AppUser> findTopByCustomerNumberExistsOrderByCustomerNumberDesc();
} }

View File

@@ -6,7 +6,11 @@ import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List; import java.util.List;
public interface FarmerRepository extends MongoRepository<Farmer, String> { public interface FarmerRepository extends MongoRepository<Farmer, String> {
List<Farmer> findByActiveTrueOrderByNameAsc(); List<Farmer> findByActiveTrueOrderByCompanyNameAsc();
List<Farmer> findByNameContainingIgnoreCaseOrderByNameAsc(String name); List<Farmer> findByAccountIdOrderByCompanyNameAsc(String accountId);
List<Farmer> findByAccountIdAndActiveTrueOrderByCompanyNameAsc(String accountId);
List<Farmer> findByCompanyNameContainingIgnoreCaseOrderByCompanyNameAsc(String companyName);
} }

View File

@@ -7,4 +7,8 @@ import java.util.List;
public interface MedicationCatalogRepository extends MongoRepository<MedicationCatalogItem, String> { public interface MedicationCatalogRepository extends MongoRepository<MedicationCatalogItem, String> {
List<MedicationCatalogItem> findByActiveTrueOrderByNameAsc(); List<MedicationCatalogItem> findByActiveTrueOrderByNameAsc();
List<MedicationCatalogItem> findByAccountIdOrderByNameAsc(String accountId);
List<MedicationCatalogItem> findByAccountIdAndActiveTrueOrderByNameAsc(String accountId);
} }

View File

@@ -7,4 +7,8 @@ import java.util.List;
public interface PathogenCatalogRepository extends MongoRepository<PathogenCatalogItem, String> { public interface PathogenCatalogRepository extends MongoRepository<PathogenCatalogItem, String> {
List<PathogenCatalogItem> findByActiveTrueOrderByNameAsc(); List<PathogenCatalogItem> findByActiveTrueOrderByNameAsc();
List<PathogenCatalogItem> findByAccountIdOrderByNameAsc(String accountId);
List<PathogenCatalogItem> findByAccountIdAndActiveTrueOrderByNameAsc(String accountId);
} }

View File

@@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@@ -11,6 +12,7 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration @Configuration
@EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
@Bean @Bean
@@ -23,6 +25,8 @@ public class SecurityConfig {
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll() .requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll()
.requestMatchers("/api/catalog/**").hasRole("CUSTOMER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated() .requestMatchers("/api/**").authenticated()
.anyRequest().permitAll() .anyRequest().permitAll()
) )

View File

@@ -14,7 +14,6 @@ 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.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.MongoTemplate;
@@ -42,7 +41,7 @@ public class CatalogService {
private static final Comparator<FarmerRow> FARMER_ROW_COMPARATOR = Comparator private static final Comparator<FarmerRow> FARMER_ROW_COMPARATOR = Comparator
.comparing(FarmerRow::active).reversed() .comparing(FarmerRow::active).reversed()
.thenComparing(FarmerRow::name, String.CASE_INSENSITIVE_ORDER) .thenComparing(FarmerRow::companyName, String.CASE_INSENSITIVE_ORDER)
.thenComparing(FarmerRow::updatedAt, Comparator.nullsLast(Comparator.reverseOrder())); .thenComparing(FarmerRow::updatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
private static final Comparator<MedicationRow> MEDICATION_ROW_COMPARATOR = Comparator private static final Comparator<MedicationRow> MEDICATION_ROW_COMPARATOR = Comparator
@@ -67,7 +66,6 @@ public class CatalogService {
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate; private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService; private final AuthTokenService authTokenService;
private final AuthorizationService authorizationService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService( public CatalogService(
@@ -77,8 +75,7 @@ public class CatalogService {
AntibioticCatalogRepository antibioticRepository, AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository, AppUserRepository appUserRepository,
MongoTemplate mongoTemplate, MongoTemplate mongoTemplate,
AuthTokenService authTokenService, AuthTokenService authTokenService
AuthorizationService authorizationService
) { ) {
this.farmerRepository = farmerRepository; this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository; this.medicationRepository = medicationRepository;
@@ -87,65 +84,96 @@ public class CatalogService {
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate; this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService; this.authTokenService = authTokenService;
this.authorizationService = authorizationService;
} }
public ActiveCatalogSummary activeCatalogSummary() { public ActiveCatalogSummary activeCatalogSummary(String actorId) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return new ActiveCatalogSummary( return new ActiveCatalogSummary(
farmerRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toFarmerOption).toList(), listActiveFarmersForActor(actor).stream().map(this::toFarmerOption).toList(),
medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(), listActiveMedicationsForActor(actor).stream().map(this::toMedicationOption).toList(),
pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(), listActivePathogensForActor(actor).stream().map(this::toPathogenOption).toList(),
antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).toList(), listActiveAntibioticsForActor(actor).stream().map(this::toAntibioticOption).toList(),
List.of() List.of()
); );
} }
public AdministrationOverview administrationOverview(String actorId) { public AdministrationOverview administrationOverview(String actorId) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows()); return new AdministrationOverview(
listFarmerRowsForActor(actor),
listMedicationRowsForActor(actor),
listPathogenRowsForActor(actor),
listAntibioticRowsForActor(actor)
);
} }
public List<FarmerRow> listFarmerRows() { // Hilfsmethoden für Datenzugriff (immer nur eigene Daten des Hauptbenutzers)
return farmerRepository.findAll().stream() private List<Farmer> listActiveFarmersForActor(AppUser actor) {
return farmerRepository.findByAccountIdAndActiveTrueOrderByCompanyNameAsc(resolveAccountId(actor));
}
private List<MedicationCatalogItem> listActiveMedicationsForActor(AppUser actor) {
return medicationRepository.findByAccountIdAndActiveTrueOrderByNameAsc(resolveAccountId(actor));
}
private List<PathogenCatalogItem> listActivePathogensForActor(AppUser actor) {
return pathogenRepository.findByAccountIdAndActiveTrueOrderByNameAsc(resolveAccountId(actor));
}
private List<AntibioticCatalogItem> listActiveAntibioticsForActor(AppUser actor) {
return antibioticRepository.findByAccountIdAndActiveTrueOrderByNameAsc(resolveAccountId(actor));
}
private List<FarmerRow> listFarmerRowsForActor(AppUser actor) {
return farmerRepository.findByAccountIdOrderByCompanyNameAsc(resolveAccountId(actor)).stream()
.map(this::toFarmerRow) .map(this::toFarmerRow)
.sorted(FARMER_ROW_COMPARATOR) .sorted(FARMER_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<MedicationRow> listMedicationRows() { private List<MedicationRow> listMedicationRowsForActor(AppUser actor) {
return medicationRepository.findAll().stream() return medicationRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toMedicationRow) .map(this::toMedicationRow)
.sorted(MEDICATION_ROW_COMPARATOR) .sorted(MEDICATION_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<PathogenRow> listPathogenRows() { private List<PathogenRow> listPathogenRowsForActor(AppUser actor) {
return pathogenRepository.findAll().stream() return pathogenRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toPathogenRow) .map(this::toPathogenRow)
.sorted(PATHOGEN_ROW_COMPARATOR) .sorted(PATHOGEN_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<AntibioticRow> listAntibioticRows() { private List<AntibioticRow> listAntibioticRowsForActor(AppUser actor) {
return antibioticRepository.findAll().stream() return antibioticRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toAntibioticRow) .map(this::toAntibioticRow)
.sorted(ANTIBIOTIC_ROW_COMPARATOR) .sorted(ANTIBIOTIC_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<FarmerRow> saveFarmers(String actorId, List<FarmerMutation> mutations) { public List<FarmerRow> saveFarmers(String actorId, List<FarmerMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (FarmerMutation mutation : mutations) { for (FarmerMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.companyName())) {
continue; continue;
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
mutation.name().trim(), blankToNull(mutation.customerNumber()),
mutation.companyName().trim(),
blankToNull(mutation.contactPerson()),
blankToNull(mutation.street()),
blankToNull(mutation.houseNumber()),
blankToNull(mutation.postalCode()),
blankToNull(mutation.city()),
blankToNull(mutation.email()), blankToNull(mutation.email()),
blankToNull(mutation.phoneNumber()),
mutation.active(), mutation.active(),
null, null,
now, now,
@@ -156,14 +184,33 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Landwirt-ID fehlt"); String mutationId = requireText(mutation.id(), "Landwirt-ID fehlt");
Farmer existing = farmerRepository.findById(mutationId) Farmer existing = farmerRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Landwirt nicht gefunden"));
boolean changed = !existing.name().equals(mutation.name().trim()) // Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
|| !safeEquals(existing.email(), blankToNull(mutation.email())); if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.companyName().equals(mutation.companyName().trim())
|| !safeEquals(existing.customerNumber(), blankToNull(mutation.customerNumber()))
|| !safeEquals(existing.contactPerson(), blankToNull(mutation.contactPerson()))
|| !safeEquals(existing.street(), blankToNull(mutation.street()))
|| !safeEquals(existing.houseNumber(), blankToNull(mutation.houseNumber()))
|| !safeEquals(existing.postalCode(), blankToNull(mutation.postalCode()))
|| !safeEquals(existing.city(), blankToNull(mutation.city()))
|| !safeEquals(existing.email(), blankToNull(mutation.email()))
|| !safeEquals(existing.phoneNumber(), blankToNull(mutation.phoneNumber()));
if (changed) { if (changed) {
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.customerNumber(),
existing.companyName(),
existing.contactPerson(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(), existing.email(),
existing.phoneNumber(),
false, false,
existing.supersedesId(), existing.supersedesId(),
existing.createdAt(), existing.createdAt(),
@@ -171,9 +218,17 @@ public class CatalogService {
)); ));
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
mutation.name().trim(), blankToNull(mutation.customerNumber()),
mutation.companyName().trim(),
blankToNull(mutation.contactPerson()),
blankToNull(mutation.street()),
blankToNull(mutation.houseNumber()),
blankToNull(mutation.postalCode()),
blankToNull(mutation.city()),
blankToNull(mutation.email()), blankToNull(mutation.email()),
blankToNull(mutation.phoneNumber()),
mutation.active(), mutation.active(),
existing.id(), existing.id(),
now, now,
@@ -184,9 +239,17 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.customerNumber(),
existing.companyName(),
existing.contactPerson(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(), existing.email(),
existing.phoneNumber(),
mutation.active(), mutation.active(),
existing.supersedesId(), existing.supersedesId(),
existing.createdAt(), existing.createdAt(),
@@ -194,11 +257,12 @@ public class CatalogService {
)); ));
} }
} }
return listFarmerRows(); return listFarmerRowsForActor(actor);
} }
public List<MedicationRow> saveMedications(String actorId, List<MedicationMutation> mutations) { public List<MedicationRow> saveMedications(String actorId, List<MedicationMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (MedicationMutation mutation : mutations) { for (MedicationMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.category() == null) { if (isBlank(mutation.name()) || mutation.category() == null) {
continue; continue;
@@ -207,6 +271,7 @@ public class CatalogService {
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
mutation.name().trim(), mutation.name().trim(),
mutation.category(), mutation.category(),
@@ -220,11 +285,16 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Medikament-ID fehlt"); String mutationId = requireText(mutation.id(), "Medikament-ID fehlt");
MedicationCatalogItem existing = medicationRepository.findById(mutationId) MedicationCatalogItem existing = medicationRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Medikament nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Medikament nicht gefunden"));
// Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.name().equals(mutation.name().trim()) boolean changed = !existing.name().equals(mutation.name().trim())
|| existing.category() != mutation.category(); || existing.category() != mutation.category();
if (changed) { if (changed) {
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.name(),
existing.category(), existing.category(),
@@ -235,6 +305,7 @@ public class CatalogService {
)); ));
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
mutation.name().trim(), mutation.name().trim(),
mutation.category(), mutation.category(),
@@ -248,6 +319,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.name(),
existing.category(), existing.category(),
@@ -258,11 +330,12 @@ public class CatalogService {
)); ));
} }
} }
return listMedicationRows(); return listMedicationRowsForActor(actor);
} }
public List<PathogenRow> savePathogens(String actorId, List<PathogenMutation> mutations) { public List<PathogenRow> savePathogens(String actorId, List<PathogenMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (PathogenMutation mutation : mutations) { for (PathogenMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.kind() == null) { if (isBlank(mutation.name()) || mutation.kind() == null) {
continue; continue;
@@ -271,6 +344,7 @@ public class CatalogService {
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -285,12 +359,17 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Erreger-ID fehlt"); String mutationId = requireText(mutation.id(), "Erreger-ID fehlt");
PathogenCatalogItem existing = pathogenRepository.findById(mutationId) PathogenCatalogItem existing = pathogenRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Erreger nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Erreger nicht gefunden"));
// Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.name().equals(mutation.name().trim()) boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.code(), blankToNull(mutation.code())) || !safeEquals(existing.code(), blankToNull(mutation.code()))
|| existing.kind() != mutation.kind(); || existing.kind() != mutation.kind();
if (changed) { if (changed) {
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -302,6 +381,7 @@ public class CatalogService {
)); ));
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -316,6 +396,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -327,11 +408,12 @@ public class CatalogService {
)); ));
} }
} }
return listPathogenRows(); return listPathogenRowsForActor(actor);
} }
public List<AntibioticRow> saveAntibiotics(String actorId, List<AntibioticMutation> mutations) { public List<AntibioticRow> saveAntibiotics(String actorId, List<AntibioticMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (AntibioticMutation mutation : mutations) { for (AntibioticMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.name())) {
continue; continue;
@@ -340,6 +422,7 @@ public class CatalogService {
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -353,11 +436,16 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Antibiotika-ID fehlt"); String mutationId = requireText(mutation.id(), "Antibiotika-ID fehlt");
AntibioticCatalogItem existing = antibioticRepository.findById(mutationId) AntibioticCatalogItem existing = antibioticRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Antibiotikum nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Antibiotikum nicht gefunden"));
// Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.name().equals(mutation.name().trim()) boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.code(), blankToNull(mutation.code())); || !safeEquals(existing.code(), blankToNull(mutation.code()));
if (changed) { if (changed) {
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -368,6 +456,7 @@ public class CatalogService {
)); ));
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -381,6 +470,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -391,7 +481,7 @@ public class CatalogService {
)); ));
} }
} }
return listAntibioticRows(); return listAntibioticRowsForActor(actor);
} }
public List<UserRow> listUsers(String actorId) { public List<UserRow> listUsers(String actorId) {
@@ -421,6 +511,7 @@ public class CatalogService {
} }
String userId = UUID.randomUUID().toString(); String userId = UUID.randomUUID().toString();
boolean adminManaged = actor.role() == UserRole.ADMIN; boolean adminManaged = actor.role() == UserRole.ADMIN;
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
userId, userId,
adminManaged ? userId : resolveAccountId(actor), adminManaged ? userId : resolveAccountId(actor),
@@ -444,6 +535,7 @@ public class CatalogService {
mutation.active(), mutation.active(),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER, adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
100000L, 100000L,
customerNumber,
now, now,
now now
)); ));
@@ -481,6 +573,7 @@ public class CatalogService {
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role())) ? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()), : normalizeStoredRole(existing.role()),
existing.nextSampleNumber(), existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(), existing.createdAt(),
now now
)); ));
@@ -532,6 +625,7 @@ public class CatalogService {
existing.active(), existing.active(),
existing.role(), existing.role(),
existing.nextSampleNumber(), existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(), existing.createdAt(),
LocalDateTime.now() LocalDateTime.now()
)); ));
@@ -578,6 +672,7 @@ public class CatalogService {
String address = formatAddress(street, houseNumber, postalCode, city); String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName; String displayName = companyName;
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
@@ -600,6 +695,7 @@ public class CatalogService {
false, false,
UserRole.CUSTOMER, UserRole.CUSTOMER,
100000L, 100000L,
customerNumber,
now, now,
now now
)); ));
@@ -624,6 +720,7 @@ public class CatalogService {
false, false,
created.role(), created.role(),
created.nextSampleNumber(), created.nextSampleNumber(),
created.customerNumber(),
created.createdAt(), created.createdAt(),
created.updatedAt() created.updatedAt()
)); ));
@@ -645,28 +742,34 @@ public class CatalogService {
removeLegacyUserCodeField(); removeLegacyUserCodeField();
backfillDefaultUserEmails(); backfillDefaultUserEmails();
removeLegacyPortalLoginField(); removeLegacyPortalLoginField();
migrateCustomerNumbers();
migrateCatalogAccountIds();
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN); ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
} }
public Farmer requireActiveFarmer(String businessKey) { public Farmer requireActiveFarmer(String actorId, String businessKey) {
return farmerRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveFarmersForActor(actor).stream()
.filter(farmer -> farmer.businessKey().equals(businessKey)) .filter(farmer -> farmer.businessKey().equals(businessKey))
.findFirst() .findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
} }
public Map<String, PathogenCatalogItem> activePathogensByBusinessKey() { public Map<String, PathogenCatalogItem> activePathogensByBusinessKey(String actorId) {
return pathogenRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActivePathogensForActor(actor).stream()
.collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity())); .collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity()));
} }
public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey() { public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey(String actorId) {
return antibioticRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveAntibioticsForActor(actor).stream()
.collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity())); .collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity()));
} }
public Map<String, MedicationCatalogItem> activeMedicationsByBusinessKey() { public Map<String, MedicationCatalogItem> activeMedicationsByBusinessKey(String actorId) {
return medicationRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveMedicationsForActor(actor).stream()
.collect(Collectors.toMap(MedicationCatalogItem::businessKey, Function.identity())); .collect(Collectors.toMap(MedicationCatalogItem::businessKey, Function.identity()));
} }
@@ -674,8 +777,15 @@ public class CatalogService {
return new FarmerRow( return new FarmerRow(
farmer.id(), farmer.id(),
farmer.businessKey(), farmer.businessKey(),
farmer.name(), farmer.customerNumber(),
farmer.companyName(),
farmer.contactPerson(),
farmer.street(),
farmer.houseNumber(),
farmer.postalCode(),
farmer.city(),
farmer.email(), farmer.email(),
farmer.phoneNumber(),
farmer.active(), farmer.active(),
farmer.updatedAt() farmer.updatedAt()
); );
@@ -734,12 +844,13 @@ public class CatalogService {
user.bic(), user.bic(),
user.active(), user.active(),
normalizeStoredRole(user.role()), normalizeStoredRole(user.role()),
user.customerNumber(),
user.updatedAt() user.updatedAt()
); );
} }
private FarmerOption toFarmerOption(Farmer farmer) { private FarmerOption toFarmerOption(Farmer farmer) {
return new FarmerOption(farmer.businessKey(), farmer.name(), farmer.email()); return new FarmerOption(farmer.businessKey(), farmer.companyName(), farmer.contactPerson(), farmer.email());
} }
private MedicationOption toMedicationOption(MedicationCatalogItem item) { private MedicationOption toMedicationOption(MedicationCatalogItem item) {
@@ -771,7 +882,8 @@ public class CatalogService {
user.bankName(), user.bankName(),
user.iban(), user.iban(),
user.bic(), user.bic(),
normalizeStoredRole(user.role()) normalizeStoredRole(user.role()),
user.customerNumber()
); );
} }
@@ -871,11 +983,64 @@ public class CatalogService {
user.active(), user.active(),
normalizeStoredRole(user.role()), normalizeStoredRole(user.role()),
user.nextSampleNumber(), user.nextSampleNumber(),
user.customerNumber(),
user.createdAt(), user.createdAt(),
now now
))); )));
} }
private void migrateCustomerNumbers() {
LocalDateTime now = LocalDateTime.now();
appUserRepository.findAll().stream()
.filter(user -> isBlank(user.customerNumber()))
.forEach(user -> appUserRepository.save(new AppUser(
user.id(),
user.accountId(),
user.primaryUser(),
user.displayName(),
user.companyName(),
user.address(),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.passwordHash(),
user.active(),
user.role(),
user.nextSampleNumber(),
generateNextCustomerNumber(),
user.createdAt(),
now
)));
}
private String generateNextCustomerNumber() {
List<AppUser> usersWithNumbers = appUserRepository.findTopByCustomerNumberExistsOrderByCustomerNumberDesc();
int nextNumber = 1000;
if (!usersWithNumbers.isEmpty()) {
String highestNumber = usersWithNumbers.get(0).customerNumber();
if (highestNumber != null && highestNumber.startsWith("K")) {
try {
int highest = Integer.parseInt(highestNumber.substring(1));
nextNumber = highest + 1;
} catch (NumberFormatException e) {
// Fallback to 1000 if parsing fails
}
}
}
// Ensure uniqueness in case of concurrent operations
while (appUserRepository.findByCustomerNumber("K" + nextNumber).isPresent()) {
nextNumber++;
}
return "K" + nextNumber;
}
private void ensureDefaultUser( private void ensureDefaultUser(
String displayName, String displayName,
String email, String email,
@@ -910,6 +1075,7 @@ public class CatalogService {
true, true,
role, role,
100000L, 100000L,
generateNextCustomerNumber(),
now, now,
now now
)); ));
@@ -935,6 +1101,93 @@ public class CatalogService {
backfillDefaultUserEmail("admin", "admin@muh.local"); backfillDefaultUserEmail("admin", "admin@muh.local");
} }
private void migrateCatalogAccountIds() {
// Finde den ersten Admin-Benutzer als Fallback
String defaultAccountId = appUserRepository.findAll().stream()
.filter(user -> user.role() == UserRole.ADMIN)
.findFirst()
.map(AppUser::id)
.orElse(null);
if (defaultAccountId == null) {
return;
}
LocalDateTime now = LocalDateTime.now();
// Migriere Farmers ohne accountId oder mit altem Schema
farmerRepository.findAll().stream()
.filter(farmer -> isBlank(farmer.accountId()) || isBlank(farmer.companyName()))
.forEach(farmer -> {
// Wenn companyName fehlt, nutze businessKey als Fallback
String companyName = isBlank(farmer.companyName()) ? farmer.businessKey() : farmer.companyName();
farmerRepository.save(new Farmer(
farmer.id(),
isBlank(farmer.accountId()) ? defaultAccountId : farmer.accountId(),
farmer.businessKey(),
null, // customerNumber
companyName,
null, // contactPerson
null, // street
null, // houseNumber
null, // postalCode
null, // city
farmer.email(),
null, // phoneNumber
farmer.active(),
farmer.supersedesId(),
farmer.createdAt(),
now
));
});
// Migriere Medications ohne accountId
medicationRepository.findAll().stream()
.filter(med -> isBlank(med.accountId()))
.forEach(med -> medicationRepository.save(new MedicationCatalogItem(
med.id(),
defaultAccountId,
med.businessKey(),
med.name(),
med.category(),
med.active(),
med.supersedesId(),
med.createdAt(),
now
)));
// Migriere Pathogens ohne accountId
pathogenRepository.findAll().stream()
.filter(pathogen -> isBlank(pathogen.accountId()))
.forEach(pathogen -> pathogenRepository.save(new PathogenCatalogItem(
pathogen.id(),
defaultAccountId,
pathogen.businessKey(),
pathogen.code(),
pathogen.name(),
pathogen.kind(),
pathogen.active(),
pathogen.supersedesId(),
pathogen.createdAt(),
now
)));
// Migriere Antibiotics ohne accountId
antibioticRepository.findAll().stream()
.filter(antibiotic -> isBlank(antibiotic.accountId()))
.forEach(antibiotic -> antibioticRepository.save(new AntibioticCatalogItem(
antibiotic.id(),
defaultAccountId,
antibiotic.businessKey(),
antibiotic.code(),
antibiotic.name(),
antibiotic.active(),
antibiotic.supersedesId(),
antibiotic.createdAt(),
now
)));
}
private void backfillDefaultUserEmail(String legacyPortalLogin, String email) { private void backfillDefaultUserEmail(String legacyPortalLogin, String email) {
mongoTemplate.updateMulti( mongoTemplate.updateMulti(
new Query(new Criteria().andOperator( new Query(new Criteria().andOperator(
@@ -1009,7 +1262,7 @@ public class CatalogService {
) { ) {
} }
public record FarmerOption(String businessKey, String name, String email) { public record FarmerOption(String businessKey, String companyName, String contactPerson, String email) {
} }
public record MedicationOption(String businessKey, String name, MedicationCategory category) { public record MedicationOption(String businessKey, String name, MedicationCategory category) {
@@ -1037,21 +1290,41 @@ public class CatalogService {
String bankName, String bankName,
String iban, String iban,
String bic, String bic,
UserRole role UserRole role,
String customerNumber
) { ) {
} }
public record FarmerRow( public record FarmerRow(
String id, String id,
String businessKey, String businessKey,
String name, String customerNumber,
String companyName,
String contactPerson,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber,
boolean active, boolean active,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
} }
public record FarmerMutation(String id, String name, String email, boolean active) { public record FarmerMutation(
String id,
String customerNumber,
String companyName,
String contactPerson,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
boolean active
) {
} }
public record MedicationRow( public record MedicationRow(
@@ -1112,6 +1385,7 @@ public class CatalogService {
String bic, String bic,
boolean active, boolean active,
UserRole role, UserRole role,
String customerNumber,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
} }

View File

@@ -0,0 +1,20 @@
package de.svencarstensen.muh.service;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class DefaultUserInitializer implements ApplicationRunner {
private final CatalogService catalogService;
public DefaultUserInitializer(CatalogService catalogService) {
this.catalogService = catalogService;
}
@Override
public void run(ApplicationArguments args) {
catalogService.ensureDefaultUsers();
}
}

View File

@@ -1,76 +0,0 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AntibioticCatalogItem;
import de.svencarstensen.muh.domain.Farmer;
import de.svencarstensen.muh.domain.MedicationCatalogItem;
import de.svencarstensen.muh.domain.MedicationCategory;
import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.repository.AntibioticCatalogRepository;
import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
@Component
public class DemoDataInitializer implements ApplicationRunner {
private final FarmerRepository farmerRepository;
private final MedicationCatalogRepository medicationRepository;
private final PathogenCatalogRepository pathogenRepository;
private final AntibioticCatalogRepository antibioticRepository;
private final CatalogService catalogService;
public DemoDataInitializer(
FarmerRepository farmerRepository,
MedicationCatalogRepository medicationRepository,
PathogenCatalogRepository pathogenRepository,
AntibioticCatalogRepository antibioticRepository,
CatalogService catalogService
) {
this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository;
this.pathogenRepository = pathogenRepository;
this.antibioticRepository = antibioticRepository;
this.catalogService = catalogService;
}
@Override
public void run(ApplicationArguments args) {
LocalDateTime now = LocalDateTime.now();
if (farmerRepository.count() == 0) {
farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Hof Hansen", "hansen@example.com", true, null, now, now));
farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Agrar Lindenblick", "lindenblick@example.com", true, null, now, now));
farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Gut Westerkamp", "westerkamp@example.com", true, null, now, now));
}
if (medicationRepository.count() == 0) {
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Mastijet", MedicationCategory.IN_UDDER, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Metacam", MedicationCategory.SYSTEMIC_PAIN, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Cobactan", MedicationCategory.SYSTEMIC_ANTIBIOTIC, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Orbeseal", MedicationCategory.DRY_SEALER, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Nafpenzal", MedicationCategory.DRY_ANTIBIOTIC, true, null, now, now));
}
if (pathogenRepository.count() == 0) {
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "SAU", "Staph. aureus", PathogenKind.BACTERIAL, true, null, now, now));
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "ECO", "E. coli", PathogenKind.BACTERIAL, true, null, now, now));
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "NG", "Kein Wachstum", PathogenKind.NO_GROWTH, true, null, now, now));
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "VER", "Verunreinigt", PathogenKind.CONTAMINATED, true, null, now, now));
}
if (antibioticRepository.count() == 0) {
antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "PEN", "Penicillin", true, null, now, now));
antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "CEF", "Cefalexin", true, null, now, now));
antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "ENR", "Enrofloxacin", true, null, now, now));
}
catalogService.ensureDefaultUsers();
}
}

View File

@@ -0,0 +1,220 @@
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.SystemPricing;
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.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
@Service
public class InvoiceService {
private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final InvoiceTemplateRepository invoiceTemplateRepository;
private final SystemPricingService pricingService;
public InvoiceService(
AppUserRepository appUserRepository,
TemplateRepository templateRepository,
InvoiceTemplateRepository invoiceTemplateRepository,
SystemPricingService pricingService
) {
this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.invoiceTemplateRepository = invoiceTemplateRepository;
this.pricingService = pricingService;
}
public List<CustomerDto> listPrimaryCustomers() {
return appUserRepository.findAll().stream()
.filter(user -> Boolean.TRUE.equals(user.primaryUser()))
.filter(user -> user.role() != de.svencarstensen.muh.domain.UserRole.ADMIN)
.map(this::toCustomerDto)
.sorted((a, b) -> a.displayName().compareToIgnoreCase(b.displayName()))
.toList();
}
public InvoiceData getInvoiceData(String actorId, String customerId) {
AppUser customer = appUserRepository.findById(customerId != null ? customerId : "")
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kunde nicht gefunden"));
if (!Boolean.TRUE.equals(customer.primaryUser())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nur Hauptbenutzer können Rechnungen erhalten");
}
// Use the issuing admin's template for invoice generation
List<TemplateElementDto> templateElements = getTemplateElements(actorId);
// Generate invoice number: R-YYYY-NNNN
String invoiceNumber = generateInvoiceNumber();
// Calculate dates
LocalDate invoiceDate = LocalDate.now();
LocalDate dueDate = invoiceDate.plusDays(14);
// Get pricing
double monthlyPrice = getMonthlyPrice();
double vatRate = 0.19;
double netAmount = monthlyPrice;
double vatAmount = netAmount * vatRate;
double grossAmount = netAmount + vatAmount;
return new InvoiceData(
invoiceNumber,
invoiceDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
dueDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
toCustomerDto(customer),
templateElements,
netAmount,
vatAmount,
grossAmount,
monthlyPrice
);
}
private String generateInvoiceNumber() {
// Format: R-YYYY-NNNN (starting from 1000)
String year = String.valueOf(LocalDate.now().getYear());
// For now, use a simple counter based on current time
// In production, this should query the database for the last invoice number
int sequence = (int) (System.currentTimeMillis() % 9000) + 1000;
return "R-" + year + "-" + sequence;
}
private double getMonthlyPrice() {
try {
var pricing = pricingService.getCurrentPricing();
return pricing.map(SystemPricing::monthlyPrice).orElse(49.00);
} catch (Exception e) {
return 49.00; // Default price
}
}
private List<TemplateElementDto> getTemplateElements(String userId) {
// Try to get user's template first
Optional<Template> userTemplate = templateRepository.findByUserIdAndType(userId, TemplateType.INVOICE);
if (userTemplate.isPresent()) {
return mapTemplateElements(userTemplate.get().elements());
}
// Fall back to legacy template
Optional<InvoiceTemplate> legacyTemplate = invoiceTemplateRepository.findById(userId != null ? userId : "");
if (legacyTemplate.isPresent()) {
return mapTemplateElements(legacyTemplate.get().elements());
}
// Return empty list - frontend will use default layout
return List.of();
}
private List<TemplateElementDto> mapTemplateElements(List<?> elements) {
if (elements == null) {
return List.of();
}
return elements.stream()
.filter(InvoiceTemplateElement.class::isInstance)
.map(InvoiceTemplateElement.class::cast)
.map(element -> new TemplateElementDto(
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()
))
.toList();
}
private CustomerDto toCustomerDto(AppUser user) {
return new CustomerDto(
user.id(),
user.displayName(),
user.companyName(),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.customerNumber()
);
}
public record CustomerDto(
String id,
String displayName,
String companyName,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String customerNumber
) {
}
public record InvoiceData(
String invoiceNumber,
String invoiceDate,
String dueDate,
CustomerDto customer,
List<TemplateElementDto> templateElements,
double netAmount,
double vatAmount,
double grossAmount,
double monthlyPrice
) {
}
public record TemplateElementDto(
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

@@ -30,8 +30,8 @@ public class PortalService {
Long sampleNumber, Long sampleNumber,
LocalDate date LocalDate date
) { ) {
List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary().farmers().stream() List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary(actorId).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.companyName().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT)))
.toList(); .toList();
List<PortalSampleRow> sampleRows; List<PortalSampleRow> sampleRows;

View File

@@ -102,7 +102,7 @@ public class SampleService {
public SampleDetail createSample(String actorId, RegistrationRequest request) { public SampleDetail createSample(String actorId, RegistrationRequest request) {
AppUser actor = requireActor(actorId); AppUser actor = requireActor(actorId);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary(actorId).farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
.findFirst() .findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
@@ -124,7 +124,7 @@ public class SampleService {
null, null,
sampleNumber, sampleNumber,
farmer.businessKey(), farmer.businessKey(),
farmer.name(), farmer.companyName(),
farmer.email(), farmer.email(),
request.cowNumber().trim(), request.cowNumber().trim(),
blankToNull(request.cowName()), blankToNull(request.cowName()),
@@ -157,7 +157,7 @@ public class SampleService {
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");
} }
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary(actorId).farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
.findFirst() .findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
@@ -178,7 +178,7 @@ public class SampleService {
existing.id(), existing.id(),
existing.sampleNumber(), existing.sampleNumber(),
farmer.businessKey(), farmer.businessKey(),
farmer.name(), farmer.companyName(),
farmer.email(), farmer.email(),
request.cowNumber().trim(), request.cowNumber().trim(),
blankToNull(request.cowName()), blankToNull(request.cowName()),
@@ -220,7 +220,7 @@ public class SampleService {
current.put(quarter.quarterKey(), quarter); current.put(quarter.quarterKey(), quarter);
} }
Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey(); Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey(actorId);
List<QuarterFinding> updatedQuarters = new ArrayList<>(); List<QuarterFinding> updatedQuarters = new ArrayList<>();
for (AnamnesisQuarterRequest quarterRequest : request.quarters()) { for (AnamnesisQuarterRequest quarterRequest : request.quarters()) {
QuarterFinding base = current.get(quarterRequest.quarterKey()); QuarterFinding base = current.get(quarterRequest.quarterKey());
@@ -284,7 +284,7 @@ public class SampleService {
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");
} }
Map<String, de.svencarstensen.muh.domain.AntibioticCatalogItem> antibiotics = catalogService.activeAntibioticsByBusinessKey(); Map<String, de.svencarstensen.muh.domain.AntibioticCatalogItem> antibiotics = catalogService.activeAntibioticsByBusinessKey(actorId);
Map<QuarterKey, QuarterAntibiogram> groups = new HashMap<>(); Map<QuarterKey, QuarterAntibiogram> groups = new HashMap<>();
Map<QuarterKey, QuarterFinding> quartersByKey = existing.quarters().stream() Map<QuarterKey, QuarterFinding> quartersByKey = existing.quarters().stream()
.collect(java.util.stream.Collectors.toMap(QuarterFinding::quarterKey, quarter -> quarter)); .collect(java.util.stream.Collectors.toMap(QuarterFinding::quarterKey, quarter -> quarter));
@@ -435,7 +435,7 @@ public class SampleService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Therapie kann nicht bearbeitet werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Therapie kann nicht bearbeitet werden");
} }
Map<String, de.svencarstensen.muh.domain.MedicationCatalogItem> medications = catalogService.activeMedicationsByBusinessKey(); Map<String, de.svencarstensen.muh.domain.MedicationCatalogItem> medications = catalogService.activeMedicationsByBusinessKey(actorId);
TherapyRecommendation therapy = new TherapyRecommendation( TherapyRecommendation therapy = new TherapyRecommendation(
request.continueStarted(), request.continueStarted(),
request.switchTherapy(), request.switchTherapy(),
@@ -622,6 +622,7 @@ public class SampleService {
actor.active(), actor.active(),
actor.role(), actor.role(),
sampleNumber + 1, sampleNumber + 1,
actor.customerNumber(),
actor.createdAt(), actor.createdAt(),
LocalDateTime.now() LocalDateTime.now()
)); ));

View File

@@ -26,30 +26,57 @@ public class CatalogController {
@GetMapping("/catalogs/summary") @GetMapping("/catalogs/summary")
public CatalogService.ActiveCatalogSummary catalogSummary() { public CatalogService.ActiveCatalogSummary catalogSummary() {
return catalogService.activeCatalogSummary(); return catalogService.activeCatalogSummary(securitySupport.currentUser().id());
} }
// Legacy admin endpoints - ADMIN only
@GetMapping("/admin") @GetMapping("/admin")
public CatalogService.AdministrationOverview administrationOverview() { public CatalogService.AdministrationOverview administrationOverview() {
return catalogService.administrationOverview(securitySupport.currentUser().id()); 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> saveFarmersAdmin(@RequestBody List<CatalogService.FarmerMutation> mutations) {
return catalogService.saveFarmers(securitySupport.currentUser().id(), 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> saveMedicationsAdmin(@RequestBody List<CatalogService.MedicationMutation> mutations) {
return catalogService.saveMedications(securitySupport.currentUser().id(), 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> savePathogensAdmin(@RequestBody List<CatalogService.PathogenMutation> mutations) {
return catalogService.savePathogens(securitySupport.currentUser().id(), mutations); return catalogService.savePathogens(securitySupport.currentUser().id(), mutations);
} }
@PostMapping("/admin/antibiotics") @PostMapping("/admin/antibiotics")
public List<CatalogService.AntibioticRow> saveAntibioticsAdmin(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations);
}
// New catalog endpoints - ADMIN and CUSTOMER
@GetMapping("/catalog/overview")
public CatalogService.AdministrationOverview catalogOverview() {
return catalogService.administrationOverview(securitySupport.currentUser().id());
}
@PostMapping("/catalog/farmers")
public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) {
return catalogService.saveFarmers(securitySupport.currentUser().id(), mutations);
}
@PostMapping("/catalog/medications")
public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) {
return catalogService.saveMedications(securitySupport.currentUser().id(), mutations);
}
@PostMapping("/catalog/pathogens")
public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) {
return catalogService.savePathogens(securitySupport.currentUser().id(), mutations);
}
@PostMapping("/catalog/antibiotics")
public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) { public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations); return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations);
} }

View File

@@ -0,0 +1,68 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.InvoiceService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
public class InvoiceController {
private final InvoiceService invoiceService;
private final SecuritySupport securitySupport;
public InvoiceController(InvoiceService invoiceService, SecuritySupport securitySupport) {
this.invoiceService = invoiceService;
this.securitySupport = securitySupport;
}
@GetMapping("/customers/primary")
public List<InvoiceService.CustomerDto> listPrimaryCustomers() {
return invoiceService.listPrimaryCustomers();
}
@GetMapping("/customers/{customerId}/invoice-data")
public ResponseEntity<?> getInvoiceData(@PathVariable String customerId) {
try {
InvoiceService.InvoiceData data = invoiceService.getInvoiceData(
securitySupport.currentUser().id(),
customerId
);
return ResponseEntity.ok(data);
} catch (ResponseStatusException e) {
return ResponseEntity.status(e.getStatusCode()).body(Map.of("message", e.getReason()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Fehler beim Erstellen der Rechnung: " + e.getMessage()));
}
}
@GetMapping("/invoices")
public InvoiceOverview getInvoices() {
// Mock implementation - returns empty list for now
return new InvoiceOverview(List.of());
}
public record InvoiceOverview(List<InvoiceSummary> invoices) {
}
public record InvoiceSummary(
String id,
String invoiceNumber,
String customerName,
String invoiceDate,
String dueDate,
double totalAmount,
String status
) {
}
}

View File

@@ -27,7 +27,8 @@ export type UserRole = "ADMIN" | "CUSTOMER";
export interface FarmerOption { export interface FarmerOption {
businessKey: string; businessKey: string;
name: string; companyName: string;
contactPerson: string | null;
email: string | null; email: string | null;
} }
@@ -67,6 +68,7 @@ export interface UserOption {
iban: string | null; iban: string | null;
bic: string | null; bic: string | null;
role: UserRole; role: UserRole;
customerNumber: string | null;
} }
export interface SessionResponse { export interface SessionResponse {
@@ -203,8 +205,15 @@ export interface SampleDetail {
export interface FarmerRow { export interface FarmerRow {
id: string; id: string;
businessKey: string; businessKey: string;
name: string; customerNumber: string | null;
companyName: string;
contactPerson: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null; email: string | null;
phoneNumber: string | null;
active: boolean; active: boolean;
updatedAt: string; updatedAt: string;
} }

View File

@@ -6,7 +6,7 @@
* - MINOR: New functionality (backward compatible) * - MINOR: New functionality (backward compatible)
* - PATCH: Bug fixes (backward compatible) * - PATCH: Bug fixes (backward compatible)
*/ */
export const APP_VERSION = "0.8.0"; export const APP_VERSION = "0.9.2";
/** /**
* Build date - set at build time * Build date - set at build time

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiGet } from "../lib/api"; import { apiGet } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
import {
createDefaultInvoiceStarterLayout,
createMuhInvoiceContent,
createPdfBlob as createTemplatePdfBlob,
normalizeTemplateElements,
type TemplateElement,
} from "./InvoiceTemplatePage";
interface InvoiceSummary { interface InvoiceSummary {
id: string; id: string;
@@ -15,6 +24,35 @@ interface InvoiceOverview {
invoices: InvoiceSummary[]; invoices: InvoiceSummary[];
} }
interface CustomerDto {
id: string;
displayName: string;
companyName: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
phoneNumber: string | null;
accountHolder: string | null;
bankName: string | null;
iban: string | null;
bic: string | null;
customerNumber: string | null;
}
interface InvoiceData {
invoiceNumber: string;
invoiceDate: string;
dueDate: string;
customer: CustomerDto;
templateElements: unknown[];
netAmount: number;
vatAmount: number;
grossAmount: number;
monthlyPrice: number;
}
const STATUS_LABELS: Record<InvoiceSummary["status"], string> = { const STATUS_LABELS: Record<InvoiceSummary["status"], string> = {
DRAFT: "Entwurf", DRAFT: "Entwurf",
SENT: "Versendet", SENT: "Versendet",
@@ -31,11 +69,127 @@ const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
CANCELLED: "status-badge--neutral", CANCELLED: "status-badge--neutral",
}; };
function buildIssuerContact(user: UserOption | null) {
const lines: string[] = [];
if (user?.phoneNumber) {
lines.push(`Tel.: ${user.phoneNumber}`);
}
if (user?.email) {
lines.push(`E-Mail: ${user.email}`);
}
return lines.join("\n");
}
function buildBankDetails(user: UserOption | null) {
const lines = ["Bankverbindung:"];
if (user?.accountHolder) {
lines.push(`Kontoinhaber: ${user.accountHolder}`);
}
if (user?.iban) {
lines.push(`IBAN: ${user.iban}`);
}
if (user?.bic) {
lines.push(`BIC: ${user.bic}`);
}
if (user?.bankName) {
lines.push(`Bank: ${user.bankName}`);
}
return lines.join("\n") || "Bankverbindung:";
}
function resolveTemplateContent(
element: TemplateElement,
invoiceData: InvoiceData,
issuer: UserOption | null,
) {
const customer = invoiceData.customer;
switch (element.paletteId) {
case "issuer-name":
return issuer?.companyName ?? issuer?.displayName ?? element.content;
case "issuer-street":
return issuer?.street ?? "";
case "issuer-house-number":
return issuer?.houseNumber ?? "";
case "issuer-postal-code":
return issuer?.postalCode ?? "";
case "issuer-city":
return issuer?.city ?? "";
case "issuer-contact":
return buildIssuerContact(issuer);
case "invoice-title":
return "Rechnung";
case "invoice-number":
return `Rechnungsnr.: ${invoiceData.invoiceNumber}`;
case "invoice-date":
return `Datum: ${invoiceData.invoiceDate}`;
case "invoice-due-date":
return `Fällig bis: ${invoiceData.dueDate}`;
case "customer-name":
return customer.companyName || customer.displayName;
case "customer-street":
return customer.street || "";
case "customer-house-number":
return customer.houseNumber || "";
case "customer-postal-code":
return customer.postalCode || "";
case "customer-city":
return customer.city || "";
case "customer-email":
return customer.email ? `E-Mail: ${customer.email}` : "";
case "customer-phone":
return customer.phoneNumber ? `Tel.: ${customer.phoneNumber}` : "";
case "customer-number":
return `Kunden-Nr.: ${customer.customerNumber || "-"}`;
case "invoice-items-muh":
return createMuhInvoiceContent(invoiceData.monthlyPrice, element.width);
case "payment-terms":
return "Zahlungsbedingungen: Zahlung innerhalb von 14 Tagen ohne Abzug.";
case "bank-details":
return buildBankDetails(issuer);
default:
return element.content;
}
}
function buildInvoiceElements(invoiceData: InvoiceData, issuer: UserOption | null) {
const storedElements = normalizeTemplateElements(invoiceData.templateElements);
const templateElements =
storedElements && storedElements.length > 0
? storedElements
: createDefaultInvoiceStarterLayout(issuer, invoiceData.monthlyPrice);
return templateElements.map((element) => {
if (element.kind !== "text") {
return element;
}
return {
...element,
content: resolveTemplateContent(element, invoiceData, issuer),
};
});
}
function createInvoicePdfBlob(invoiceData: InvoiceData, issuer: UserOption | null) {
return createTemplatePdfBlob(buildInvoiceElements(invoiceData, issuer), invoiceData.monthlyPrice);
}
export default function InvoiceManagementPage() { export default function InvoiceManagementPage() {
const { user } = useSession();
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]); const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Dialog states
const [showCustomerDialog, setShowCustomerDialog] = useState(false);
const [showPdfPreview, setShowPdfPreview] = useState(false);
const [customers, setCustomers] = useState<CustomerDto[]>([]);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>("");
const [invoiceData, setInvoiceData] = useState<InvoiceData | null>(null);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [isLoadingInvoice, setIsLoadingInvoice] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
@@ -47,10 +201,8 @@ export default function InvoiceManagementPage() {
setInvoices(response.invoices); setInvoices(response.invoices);
} }
}) })
.catch((err) => { .catch(() => {
if (!cancelled) { if (!cancelled) {
// Für den Moment zeigen wir einfach eine leere Liste an
// bis das Backend implementiert ist
setInvoices([]); setInvoices([]);
setError(null); setError(null);
} }
@@ -66,6 +218,28 @@ export default function InvoiceManagementPage() {
}; };
}, []); }, []);
// Cleanup PDF URL
useEffect(() => {
return () => {
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
};
}, [pdfUrl]);
// Handle Escape key for PDF preview
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setShowPdfPreview(false);
}
}
if (showPdfPreview) {
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}
}, [showPdfPreview]);
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("de-DE", { return new Intl.NumberFormat("de-DE", {
style: "currency", style: "currency",
@@ -79,6 +253,59 @@ export default function InvoiceManagementPage() {
}).format(new Date(dateString)); }).format(new Date(dateString));
}; };
const handleNewInvoice = async () => {
setError(null);
try {
const customerList = await apiGet<CustomerDto[]>("/admin/customers/primary");
setCustomers(customerList);
setSelectedCustomerId(customerList[0]?.id || "");
setShowCustomerDialog(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unbekannter Fehler";
setError(`Kunden konnten nicht geladen werden: ${errorMessage}`);
}
};
const handleCreateInvoice = async () => {
if (!selectedCustomerId) return;
setIsLoadingInvoice(true);
setError(null);
try {
const data = await apiGet<InvoiceData>(`/admin/customers/${selectedCustomerId}/invoice-data`);
if (!data.customer) {
throw new Error("Ungültige Rechnungsdaten: Kunde fehlt");
}
setInvoiceData(data);
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
}
const pdfBlob = createInvoicePdfBlob(data, user);
const url = URL.createObjectURL(pdfBlob);
setPdfUrl(url);
setShowCustomerDialog(false);
setShowPdfPreview(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unbekannter Fehler";
setError(`Rechnung konnte nicht erstellt werden: ${errorMessage}`);
} finally {
setIsLoadingInvoice(false);
}
};
const handleClosePdfPreview = () => {
setShowPdfPreview(false);
if (pdfUrl) {
URL.revokeObjectURL(pdfUrl);
setPdfUrl(null);
}
};
return ( return (
<div className="page-stack"> <div className="page-stack">
<section className="section-card section-card--hero"> <section className="section-card section-card--hero">
@@ -101,7 +328,11 @@ export default function InvoiceManagementPage() {
<h3>Rechnungsliste</h3> <h3>Rechnungsliste</h3>
</div> </div>
<div className="page-actions"> <div className="page-actions">
<button type="button" className="accent-button" disabled> <button
type="button"
className="accent-button"
onClick={handleNewInvoice}
>
Neue Rechnung Neue Rechnung
</button> </button>
</div> </div>
@@ -113,7 +344,7 @@ export default function InvoiceManagementPage() {
<div className="empty-state"> <div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p> <p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text"> <p className="muted-text">
Die Rechnungsverwaltung wird in Kürze verfügbar sein. Erstellen Sie Ihre erste Rechnung mit dem Button "Neue Rechnung".
</p> </p>
</div> </div>
) : ( ) : (
@@ -191,6 +422,219 @@ export default function InvoiceManagementPage() {
</table> </table>
</div> </div>
</section> </section>
{/* Customer Selection Dialog */}
{showCustomerDialog && (
<div
className="modal-overlay"
onClick={() => setShowCustomerDialog(false)}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: "white",
borderRadius: "8px",
padding: "24px",
width: "100%",
maxWidth: "480px",
maxHeight: "90vh",
overflow: "auto",
}}
>
<h3 style={{ marginTop: 0, marginBottom: "16px" }}>
Rechnung erstellen
</h3>
<p style={{ marginBottom: "20px", color: "#666" }}>
Wählen Sie einen Hauptbenutzer aus, für den die Rechnung erstellt werden soll:
</p>
<div style={{ marginBottom: "24px" }}>
<label
htmlFor="customer-select"
style={{
display: "block",
marginBottom: "8px",
fontWeight: 500,
fontSize: "14px",
}}
>
Kunde
</label>
<select
id="customer-select"
value={selectedCustomerId}
onChange={(e) => setSelectedCustomerId(e.target.value)}
style={{
width: "100%",
padding: "10px 12px",
borderRadius: "6px",
border: "1px solid #d1d5db",
fontSize: "14px",
backgroundColor: "white",
}}
>
{customers.length === 0 && (
<option value="">Keine Kunden verfügbar</option>
)}
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.companyName || customer.displayName}
{customer.customerNumber ? ` (${customer.customerNumber})` : ""}
</option>
))}
</select>
</div>
<div style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}>
<button
type="button"
className="secondary-button"
onClick={() => setShowCustomerDialog(false)}
style={{
padding: "10px 20px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
cursor: "pointer",
}}
>
Abbrechen
</button>
<button
type="button"
className="accent-button"
onClick={handleCreateInvoice}
disabled={!selectedCustomerId || isLoadingInvoice}
style={{
padding: "10px 20px",
borderRadius: "6px",
border: "none",
backgroundColor: "var(--primary-600, #2563eb)",
color: "white",
cursor: !selectedCustomerId || isLoadingInvoice ? "not-allowed" : "pointer",
opacity: !selectedCustomerId || isLoadingInvoice ? 0.6 : 1,
}}
>
{isLoadingInvoice ? "Wird erstellt..." : "Rechnung erstellen"}
</button>
</div>
</div>
</div>
)}
{/* PDF Preview Dialog */}
{showPdfPreview && pdfUrl && (
<div
className="modal-overlay"
onClick={handleClosePdfPreview}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: "white",
borderRadius: "8px",
width: "95%",
maxWidth: "900px",
height: "90vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
padding: "16px 24px",
borderBottom: "1px solid #e5e7eb",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<h3 style={{ margin: 0, fontSize: "18px" }}>
Rechnungsvorschau
</h3>
{invoiceData && (
<p style={{ margin: "4px 0 0 0", fontSize: "14px", color: "#666" }}>
{invoiceData.invoiceNumber} - {invoiceData.customer.companyName || invoiceData.customer.displayName}
</p>
)}
</div>
<div style={{ display: "flex", gap: "12px" }}>
<a
href={pdfUrl}
download={invoiceData ? `${invoiceData.invoiceNumber}.pdf` : "rechnung.pdf"}
style={{
padding: "8px 16px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
color: "#374151",
textDecoration: "none",
fontSize: "14px",
cursor: "pointer",
}}
>
Download
</a>
<button
type="button"
onClick={handleClosePdfPreview}
style={{
padding: "8px 16px",
borderRadius: "6px",
border: "none",
backgroundColor: "var(--primary-600, #2563eb)",
color: "white",
fontSize: "14px",
cursor: "pointer",
}}
>
Schließen
</button>
</div>
</div>
<div style={{ flex: 1, overflow: "auto", backgroundColor: "#f3f4f6" }}>
<iframe
src={pdfUrl}
title="Rechnungsvorschau"
style={{
width: "100%",
height: "100%",
border: "none",
}}
/>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -58,10 +58,10 @@ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
"invoice-items-muh", "invoice-items-muh",
]); ]);
type ElementKind = "text" | "line" | "image"; export type ElementKind = "text" | "line" | "image";
type LineOrientation = "horizontal" | "vertical"; export type LineOrientation = "horizontal" | "vertical";
type TextAlign = "left" | "center" | "right"; export type TextAlign = "left" | "center" | "right";
type FontWeight = 400 | 500 | 600 | 700; export type FontWeight = 400 | 500 | 600 | 700;
type PaletteCategory = string; type PaletteCategory = string;
function isMuhInvoicePaletteId(paletteId?: string) { function isMuhInvoicePaletteId(paletteId?: string) {
@@ -87,7 +87,7 @@ interface PaletteItem {
defaultContent: (user: UserOption | null) => string; defaultContent: (user: UserOption | null) => string;
} }
interface TemplateElement { export interface TemplateElement {
id: string; id: string;
paletteId: string; paletteId: string;
kind: ElementKind; kind: ElementKind;
@@ -1006,7 +1006,7 @@ function createPdfContentStream(
return commands.join("\n"); return commands.join("\n");
} }
function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) { export function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
const imageResources = createPdfImageResources(elements); const imageResources = createPdfImageResources(elements);
const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource])); const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource]));
const contentStream = createPdfContentStream(elements, imageResourceMap, monthlyPrice); const contentStream = createPdfContentStream(elements, imageResourceMap, monthlyPrice);
@@ -1227,7 +1227,7 @@ function getMuhInvoiceRows(monthlyPrice: number | null): MuhInvoiceRow[] {
]; ];
} }
function createMuhInvoiceContent(monthlyPrice: number | null, _elementWidth: number = 646): string { export function createMuhInvoiceContent(monthlyPrice: number | null, _elementWidth: number = 646): string {
return getMuhInvoiceRows(monthlyPrice) return getMuhInvoiceRows(monthlyPrice)
.map((row) => `${row.label} | ${row.amount}`) .map((row) => `${row.label} | ${row.amount}`)
.join("\n"); .join("\n");
@@ -1319,6 +1319,13 @@ function createInvoiceStarterLayout(user: UserOption | null, paletteItems: Palet
]; ];
} }
export function createDefaultInvoiceStarterLayout(
user: UserOption | null,
monthlyPrice: number | null = null,
) {
return createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS, monthlyPrice);
}
function readFileAsDataUrl(file: File) { function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -1381,7 +1388,7 @@ async function convertImageFileToJpeg(file: File) {
}; };
} }
function normalizeTemplateElements(raw: unknown) { export function normalizeTemplateElements(raw: unknown) {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return null; return null;
} }

View File

@@ -196,7 +196,7 @@ export default function SampleRegistrationPage() {
> >
{catalogs?.farmers.map((farmer) => ( {catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}> <option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name} {farmer.companyName}
</option> </option>
))} ))}
</select> </select>

View File

@@ -22,7 +22,7 @@ export default function SearchFarmerPage() {
`/portal/snapshot?farmerBusinessKey=${encodeURIComponent(farmer.businessKey)}`, `/portal/snapshot?farmerBusinessKey=${encodeURIComponent(farmer.businessKey)}`,
); );
setSamples(response.samples); setSamples(response.samples);
setResultLabel(`Proben von ${farmer.name}`); setResultLabel(`Proben von ${farmer.companyName}`);
setMessage(response.samples.length ? null : "Zu diesem Landwirt wurden noch keine Proben gefunden."); setMessage(response.samples.length ? null : "Zu diesem Landwirt wurden noch keine Proben gefunden.");
} }
@@ -121,7 +121,7 @@ export default function SearchFarmerPage() {
className="user-card" className="user-card"
onClick={() => void loadFarmerSamples(farmer)} onClick={() => void loadFarmerSamples(farmer)}
> >
<strong>{farmer.name}</strong> <strong>{farmer.companyName}</strong>
<small>{farmer.email ?? "ohne E-Mail"}</small> <small>{farmer.email ?? "ohne E-Mail"}</small>
</button> </button>
))} ))}

View File

@@ -58,6 +58,29 @@ export default function UserManagementPage() {
} }
}, [user]); }, [user]);
function resetProfileData() {
setProfileData({
displayName: user?.displayName || "",
companyName: user?.companyName || "",
street: user?.street || "",
houseNumber: user?.houseNumber || "",
postalCode: user?.postalCode || "",
city: user?.city || "",
email: user?.email || "",
phoneNumber: user?.phoneNumber || "",
});
}
function openProfileDialog() {
resetProfileData();
setShowProfileForm(true);
}
function closeProfileDialog() {
resetProfileData();
setShowProfileForm(false);
}
useEffect(() => { useEffect(() => {
async function loadUsers() { async function loadUsers() {
try { try {
@@ -170,6 +193,14 @@ export default function UserManagementPage() {
} }
} }
function closeCreateUserDialog() {
setShowCreateForm(false);
setNewUserName("");
setNewUserEmail("");
setNewUserPassword("");
setNewUserPasswordConfirm("");
}
async function handleSaveProfile(e: React.FormEvent) { async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!profileData.displayName.trim()) { if (!profileData.displayName.trim()) {
@@ -269,8 +300,7 @@ export default function UserManagementPage() {
{/* Own Profile Form (nur für Hauptbenutzer) */} {/* Own Profile Form (nur für Hauptbenutzer) */}
{isPrimaryUser && !isAdmin && ( {isPrimaryUser && !isAdmin && (
<section className="section-card"> <section className="section-card">
{!showProfileForm ? ( <div className="section-card__header user-management-page__profile-header">
<div className="section-card__header">
<div> <div>
<p className="eyebrow">Meine Stammdaten</p> <p className="eyebrow">Meine Stammdaten</p>
<h3>{user?.displayName}</h3> <h3>{user?.displayName}</h3>
@@ -278,27 +308,183 @@ export default function UserManagementPage() {
<button <button
type="button" type="button"
className="accent-button" className="accent-button"
onClick={() => setShowProfileForm(true)} onClick={openProfileDialog}
> >
Bearbeiten Bearbeiten
</button> </button>
</div> </div>
) : ( </section>
<> )}
{/* Tabelle mit Benutzern */}
<section className="section-card">
<div className="section-card__header"> <div className="section-card__header">
<div> <div>
<p className="eyebrow">Meine Stammdaten</p> <p className="eyebrow">{isAdmin ? "Hauptnutzer" : "Unterbenutzer"}</p>
<h3>Stammdaten bearbeiten</h3> <h3>{isAdmin ? "Registrierte Hauptnutzer" : "Ihre Unterbenutzer"}</h3>
</div> </div>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen...</div>
) : users.length === 0 ? (
<div className="empty-state">
{isAdmin ? "Keine Hauptnutzer vorhanden." : "Keine Unterbenutzer vorhanden."}
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
{isAdmin && <th>Firma</th>}
<th>E-Mail</th>
<th>Status</th>
<th>Letzte Änderung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{users.map((entry) => (
<tr key={entry.id} className={!entry.active ? "table-row--inactive" : ""}>
<td>
<strong>{entry.displayName}</strong>
</td>
{isAdmin && <td>{(entry as SubUserRow & { companyName?: string }).companyName ?? "-"}</td>}
<td>{entry.email ?? "-"}</td>
<td>
<span
className={`status-pill ${
entry.active ? "status-pill--active" : "status-pill--inactive"
}`}
>
{entry.active ? "Freigegeben" : "Gesperrt"}
</span>
</td>
<td className="text-muted">{formatDate(entry.updatedAt)}</td>
<td>
<button <button
type="button" type="button"
className="ghost-button" className={`action-button ${
onClick={() => setShowProfileForm(false)} entry.active ? "action-button--danger" : "action-button--success"
}`}
onClick={() => toggleUserStatus(entry.id, !entry.active)}
>
{entry.active ? "Sperren" : "Freigeben"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isPrimaryUser && !isAdmin ? (
<div className="page-actions page-actions--space-between user-management-page__create-action">
<button
type="button"
className="accent-button"
onClick={() => setShowCreateForm(true)}
>
+ Benutzer anlegen
</button>
</div>
) : null}
</section>
{isPrimaryUser && !isAdmin && showCreateForm ? (
<div className="dialog-backdrop" onClick={closeCreateUserDialog}>
<form className="dialog" onClick={(event) => event.stopPropagation()} onSubmit={handleCreateUser}>
<div className="dialog__header">
<div>
<p className="eyebrow">Neuer Unterbenutzer</p>
<h4>Benutzer anlegen</h4>
</div>
</div>
<div className="dialog__body dialog__body--form">
<div className="field-grid field-grid--2col">
<label className="field">
<span>Name *</span>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="Name des Benutzers"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>E-Mail *</span>
<input
type="email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
placeholder="email@beispiel.de"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>Passwort *</span>
<input
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Passwort"
autoComplete="new-password"
required
/>
</label>
<label className="field">
<span>Passwort wiederholen *</span>
<input
type="password"
value={newUserPasswordConfirm}
onChange={(e) => setNewUserPasswordConfirm(e.target.value)}
placeholder="Passwort wiederholen"
autoComplete="new-password"
required
/>
</label>
</div>
</div>
<div className="dialog__actions dialog__actions--start">
<button
type="button"
className="secondary-button"
onClick={closeCreateUserDialog}
disabled={creating}
> >
Abbrechen Abbrechen
</button> </button>
<button
type="submit"
className="accent-button"
disabled={creating}
>
{creating ? "Wird erstellt..." : "Benutzer erstellen"}
</button>
</div> </div>
<form onSubmit={handleSaveProfile} className="field-grid field-grid--2col"> </form>
</div>
) : null}
{isPrimaryUser && !isAdmin && showProfileForm ? (
<div className="dialog-backdrop" onClick={closeProfileDialog}>
<form className="dialog" onClick={(event) => event.stopPropagation()} onSubmit={handleSaveProfile}>
<div className="dialog__header">
<div>
<p className="eyebrow">Meine Stammdaten</p>
<h4>Stammdaten bearbeiten</h4>
</div>
</div>
<div className="dialog__body dialog__body--form">
<div className="field-grid field-grid--2col">
<label className="field"> <label className="field">
<span>Name *</span> <span>Name *</span>
<input <input
@@ -372,7 +558,18 @@ export default function UserManagementPage() {
placeholder="Telefonnummer" placeholder="Telefonnummer"
/> />
</label> </label>
<div className="field" style={{ gridColumn: "1 / -1" }}> </div>
</div>
<div className="dialog__actions dialog__actions--start">
<button
type="button"
className="secondary-button"
onClick={closeProfileDialog}
disabled={savingProfile}
>
Abbrechen
</button>
<button <button
type="submit" type="submit"
className="accent-button" className="accent-button"
@@ -382,180 +579,8 @@ export default function UserManagementPage() {
</button> </button>
</div> </div>
</form> </form>
</>
)}
</section>
)}
{/* Create Sub-user Form (nur für Hauptnutzer) */}
{isPrimaryUser && !isAdmin && (
<section className="section-card">
{!showCreateForm ? (
<div className="section-card__header">
<div>
<p className="eyebrow">Neuer Unterbenutzer</p>
<h3>Benutzer anlegen</h3>
</div> </div>
<button ) : null}
type="button"
className="accent-button"
onClick={() => setShowCreateForm(true)}
>
+ Benutzer anlegen
</button>
</div>
) : (
<>
<div className="section-card__header">
<div>
<p className="eyebrow">Neuer Unterbenutzer</p>
<h3>Benutzer anlegen</h3>
</div>
<button
type="button"
className="ghost-button"
onClick={() => setShowCreateForm(false)}
>
Abbrechen
</button>
</div>
<form onSubmit={handleCreateUser} className="field-grid field-grid--2col">
<label className="field">
<span>Name *</span>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="Name des Benutzers"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>E-Mail *</span>
<input
type="email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
placeholder="email@beispiel.de"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>Passwort *</span>
<input
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Passwort"
autoComplete="new-password"
required
/>
</label>
<label className="field">
<span>Passwort wiederholen *</span>
<input
type="password"
value={newUserPasswordConfirm}
onChange={(e) => setNewUserPasswordConfirm(e.target.value)}
placeholder="Passwort wiederholen"
autoComplete="new-password"
required
/>
</label>
<div className="field" style={{ display: "flex", alignItems: "flex-end" }}>
<button
type="submit"
className="accent-button"
disabled={creating}
style={{ width: "100%" }}
>
{creating ? "Wird erstellt..." : "Benutzer erstellen"}
</button>
</div>
</form>
</>
)}
</section>
)}
{/* Tabelle mit Benutzern */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">{isAdmin ? "Hauptnutzer" : "Unterbenutzer"}</p>
<h3>{isAdmin ? "Registrierte Hauptnutzer" : "Ihre Unterbenutzer"}</h3>
</div>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen...</div>
) : users.length === 0 ? (
<div className="empty-state">
{isAdmin ? "Keine Hauptnutzer vorhanden." : "Keine Unterbenutzer vorhanden."}
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
{isAdmin && <th>Firma</th>}
<th>E-Mail</th>
<th>Status</th>
<th>Letzte Änderung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{users.map((entry) => (
<tr key={entry.id} className={!entry.active ? "table-row--inactive" : ""}>
<td>
<strong>{entry.displayName}</strong>
</td>
{isAdmin && <td>{(entry as SubUserRow & { companyName?: string }).companyName ?? "-"}</td>}
<td>{entry.email ?? "-"}</td>
<td>
<span
className={`status-pill ${
entry.active ? "status-pill--active" : "status-pill--inactive"
}`}
>
{entry.active ? "Freigegeben" : "Gesperrt"}
</span>
</td>
<td className="text-muted">{formatDate(entry.updatedAt)}</td>
<td>
<button
type="button"
className={`action-button ${
entry.active ? "action-button--danger" : "action-button--success"
}`}
onClick={() => toggleUserStatus(entry.id, !entry.active)}
>
{entry.active ? "Sperren" : "Freigeben"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
{isAdmin
? "Hauptnutzer sind die primären Kontoinhaber. Wenn Sie einen Hauptnutzer sperren, können sich dieser und alle zugehörigen Nebennutzer nicht mehr anmelden. Die Daten bleiben erhalten und können durch Freigabe wieder aktiviert werden."
: "Unterbenutzer können Proben registrieren und bearbeiten, aber keine neuen Benutzer anlegen. Wenn Sie einen Unterbenutzer sperren, kann sich dieser nicht mehr anmelden."}
</p>
</div>
</section>
</div> </div>
); );
} }

View File

@@ -570,6 +570,67 @@ a {
overflow: auto; overflow: auto;
} }
.admin-catalog-list,
.admin-farmer-list {
display: grid;
gap: 20px;
}
.admin-catalog-section,
.admin-farmer-section {
display: grid;
gap: 16px;
}
.admin-farmer-card {
display: grid;
gap: 16px;
padding: 24px;
border: 1px solid rgba(37, 49, 58, 0.08);
border-radius: 24px;
background: rgba(255, 255, 255, 0.42);
}
.admin-catalog-form {
display: grid;
gap: 16px;
padding: 24px;
border: 1px solid rgba(37, 49, 58, 0.08);
border-radius: 24px;
background: rgba(255, 255, 255, 0.42);
}
.admin-farmer-card__row {
display: grid;
gap: 16px;
}
.admin-farmer-card__row--primary {
grid-template-columns: minmax(0, 1.8fr) minmax(0, 1.3fr) minmax(0, 1fr);
}
.admin-farmer-card__row--address {
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.05fr) minmax(0, 1.8fr) minmax(0, 0.9fr);
}
.admin-farmer-card__row--contact {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.1fr);
}
.admin-farmer-card__row--toggle {
grid-template-columns: minmax(0, 180px);
}
.admin-farmer-card__toggle {
min-width: 140px;
justify-content: center;
}
.admin-catalog-form__toggle {
min-width: 140px;
justify-content: center;
}
.data-table { .data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -590,6 +651,62 @@ a {
text-transform: uppercase; text-transform: uppercase;
} }
.admin-farmer-table__row {
cursor: pointer;
}
.admin-catalog-table__row {
cursor: pointer;
}
.admin-farmer-table__row:hover,
.admin-farmer-table__row:focus-visible,
.admin-catalog-table__row:hover,
.admin-catalog-table__row:focus-visible {
background: rgba(17, 109, 99, 0.08);
outline: none;
}
.admin-farmer-table__empty {
color: var(--muted);
text-align: center !important;
}
.admin-catalog-table__empty {
color: var(--muted);
text-align: center !important;
}
@media (max-width: 1200px) {
.admin-farmer-card__row--primary,
.admin-farmer-card__row--address,
.admin-farmer-card__row--contact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-farmer-card__row--toggle {
grid-template-columns: minmax(0, 180px);
}
.admin-farmer-card__toggle {
width: 100%;
}
}
@media (max-width: 720px) {
.admin-catalog-form,
.admin-farmer-card {
padding: 18px;
}
.admin-farmer-card__row--primary,
.admin-farmer-card__row--address,
.admin-farmer-card__row--contact,
.admin-farmer-card__row--toggle {
grid-template-columns: 1fr;
}
}
.status-pill, .status-pill,
.info-chip { .info-chip {
display: inline-flex; display: inline-flex;
@@ -781,6 +898,14 @@ a {
justify-content: flex-end; justify-content: flex-end;
} }
.user-management-page__create-action {
margin-top: 18px;
}
.user-management-page__profile-header {
margin-bottom: 0;
}
.invoice-template-page { .invoice-template-page {
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@@ -978,7 +1103,7 @@ a {
position: absolute; position: absolute;
display: grid; display: grid;
gap: 0; gap: 0;
padding: 4px 5px; padding: 4px 11px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 16px; border-radius: 16px;
background: transparent; background: transparent;
@@ -1451,6 +1576,10 @@ a {
justify-content: flex-end; justify-content: flex-end;
} }
.dialog__actions--start {
justify-content: flex-start;
}
.dialog__actions a { .dialog__actions a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1462,6 +1591,11 @@ a {
min-height: 0; min-height: 0;
} }
.dialog__body--form,
.dialog__body--farmer {
overflow: auto;
}
.dialog__body--pdf { .dialog__body--pdf {
height: min(80vh, 900px); height: min(80vh, 900px);
} }