Compare commits
5 Commits
58c78bbbbd
...
3755f4c414
| Author | SHA1 | Date | |
|---|---|---|---|
| 3755f4c414 | |||
| e7a18cd339 | |||
| f9b83a166d | |||
| 09e6d07c2d | |||
| f9e370afe2 |
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,36 +300,191 @@ 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>
|
||||||
|
<p className="eyebrow">Meine Stammdaten</p>
|
||||||
|
<h3>{user?.displayName}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="accent-button"
|
||||||
|
onClick={openProfileDialog}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
<div>
|
||||||
<p className="eyebrow">Meine Stammdaten</p>
|
<p className="eyebrow">Neuer Unterbenutzer</p>
|
||||||
<h3>{user?.displayName}</h3>
|
<h4>Benutzer anlegen</h4>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="accent-button"
|
className="secondary-button"
|
||||||
onClick={() => setShowProfileForm(true)}
|
onClick={closeCreateUserDialog}
|
||||||
|
disabled={creating}
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="accent-button"
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{creating ? "Wird erstellt..." : "Benutzer erstellen"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</form>
|
||||||
<>
|
</div>
|
||||||
<div className="section-card__header">
|
) : null}
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Meine Stammdaten</p>
|
{isPrimaryUser && !isAdmin && showProfileForm ? (
|
||||||
<h3>Stammdaten bearbeiten</h3>
|
<div className="dialog-backdrop" onClick={closeProfileDialog}>
|
||||||
</div>
|
<form className="dialog" onClick={(event) => event.stopPropagation()} onSubmit={handleSaveProfile}>
|
||||||
<button
|
<div className="dialog__header">
|
||||||
type="button"
|
<div>
|
||||||
className="ghost-button"
|
<p className="eyebrow">Meine Stammdaten</p>
|
||||||
onClick={() => setShowProfileForm(false)}
|
<h4>Stammdaten bearbeiten</h4>
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSaveProfile} className="field-grid field-grid--2col">
|
</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,190 +558,29 @@ export default function UserManagementPage() {
|
|||||||
placeholder="Telefonnummer"
|
placeholder="Telefonnummer"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="field" style={{ gridColumn: "1 / -1" }}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="accent-button"
|
|
||||||
disabled={savingProfile}
|
|
||||||
>
|
|
||||||
{savingProfile ? "Wird gespeichert..." : "Stammdaten speichern"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dialog__actions dialog__actions--start">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="accent-button"
|
className="secondary-button"
|
||||||
onClick={() => setShowCreateForm(true)}
|
onClick={closeProfileDialog}
|
||||||
|
disabled={savingProfile}
|
||||||
>
|
>
|
||||||
+ Benutzer anlegen
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="accent-button"
|
||||||
|
disabled={savingProfile}
|
||||||
|
>
|
||||||
|
{savingProfile ? "Wird gespeichert..." : "Stammdaten speichern"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</form>
|
||||||
<>
|
|
||||||
<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>
|
</div>
|
||||||
|
) : null}
|
||||||
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user