Compare commits

...

7 Commits

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

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

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

View File

@@ -4,6 +4,7 @@ WORKDIR /build/frontend
ARG VITE_API_URL=/api
ENV VITE_API_URL=${VITE_API_URL}
COPY backend/pom.xml /build/backend/pom.xml
COPY frontend/package*.json ./
RUN npm ci
@@ -19,7 +20,8 @@ RUN mvn -B -q -DskipTests dependency:go-offline
COPY backend/ ./
COPY --from=frontend-build /build/frontend/dist ./src/main/resources/static
RUN mvn -B -q -DskipTests package
RUN mvn -B -q -DskipTests package \
&& cp "$(find target -maxdepth 1 -type f -name '*.jar' ! -name '*.original' | head -n 1)" /build/backend/app.jar
FROM eclipse-temurin:21-jre-alpine AS runtime
@@ -27,7 +29,7 @@ WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
COPY --from=backend-build /build/backend/target/muh-backend-0.0.1-SNAPSHOT.jar /app/app.jar
COPY --from=backend-build /build/backend/app.jar /app/app.jar
USER spring:spring

View File

@@ -107,6 +107,7 @@ Kundenregistrierung:
- `cd frontend && npm run build`
## 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 run -d --name muh --network br0 --ip 192.168.180.26 --restart unless-stopped -e MUH_MONGODB_URL=mongodb://192.168.180.25:27017/muh -e MUH_TOKEN_SECRET=local-dev-muh-token-secret-2026-03-13 -e MUH_TOKEN_VALIDITY_HOURS=12 -e MUH_ALLOWED_ORIGINS=https://muh.appcreation.de gitea.appcreation.de/sven/muh:0.8.0

View File

@@ -12,7 +12,7 @@
<groupId>de.svencarstensen</groupId>
<artifactId>muh-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>0.9.4</version>
<name>muh-backend</name>
<description>MUH application backend</description>

View File

@@ -18,7 +18,8 @@ public class CorsConfig {
configuration.setAllowedOrigins(allowedOrigins);
configuration.setAllowedHeaders(List.of("*"));
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();
source.registerCorsConfiguration("/api/**", configuration);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import de.svencarstensen.muh.security.AuthTokenService;
import de.svencarstensen.muh.security.AuthorizationService;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.data.mongodb.core.MongoTemplate;
@@ -42,7 +41,7 @@ public class CatalogService {
private static final Comparator<FarmerRow> FARMER_ROW_COMPARATOR = Comparator
.comparing(FarmerRow::active).reversed()
.thenComparing(FarmerRow::name, String.CASE_INSENSITIVE_ORDER)
.thenComparing(FarmerRow::companyName, String.CASE_INSENSITIVE_ORDER)
.thenComparing(FarmerRow::updatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
private static final Comparator<MedicationRow> MEDICATION_ROW_COMPARATOR = Comparator
@@ -67,7 +66,6 @@ public class CatalogService {
private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService;
private final AuthorizationService authorizationService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService(
@@ -77,8 +75,7 @@ public class CatalogService {
AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository,
MongoTemplate mongoTemplate,
AuthTokenService authTokenService,
AuthorizationService authorizationService
AuthTokenService authTokenService
) {
this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository;
@@ -87,65 +84,96 @@ public class CatalogService {
this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService;
this.authorizationService = authorizationService;
}
public ActiveCatalogSummary activeCatalogSummary() {
public ActiveCatalogSummary activeCatalogSummary(String actorId) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return new ActiveCatalogSummary(
farmerRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toFarmerOption).toList(),
medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(),
pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(),
antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).toList(),
listActiveFarmersForActor(actor).stream().map(this::toFarmerOption).toList(),
listActiveMedicationsForActor(actor).stream().map(this::toMedicationOption).toList(),
listActivePathogensForActor(actor).stream().map(this::toPathogenOption).toList(),
listActiveAntibioticsForActor(actor).stream().map(this::toAntibioticOption).toList(),
List.of()
);
}
public AdministrationOverview administrationOverview(String actorId) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows());
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return new AdministrationOverview(
listFarmerRowsForActor(actor),
listMedicationRowsForActor(actor),
listPathogenRowsForActor(actor),
listAntibioticRowsForActor(actor)
);
}
public List<FarmerRow> listFarmerRows() {
return farmerRepository.findAll().stream()
// Hilfsmethoden für Datenzugriff (immer nur eigene Daten des Hauptbenutzers)
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)
.sorted(FARMER_ROW_COMPARATOR)
.toList();
}
public List<MedicationRow> listMedicationRows() {
return medicationRepository.findAll().stream()
private List<MedicationRow> listMedicationRowsForActor(AppUser actor) {
return medicationRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toMedicationRow)
.sorted(MEDICATION_ROW_COMPARATOR)
.toList();
}
public List<PathogenRow> listPathogenRows() {
return pathogenRepository.findAll().stream()
private List<PathogenRow> listPathogenRowsForActor(AppUser actor) {
return pathogenRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toPathogenRow)
.sorted(PATHOGEN_ROW_COMPARATOR)
.toList();
}
public List<AntibioticRow> listAntibioticRows() {
return antibioticRepository.findAll().stream()
private List<AntibioticRow> listAntibioticRowsForActor(AppUser actor) {
return antibioticRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toAntibioticRow)
.sorted(ANTIBIOTIC_ROW_COMPARATOR)
.toList();
}
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) {
if (isBlank(mutation.name())) {
if (isBlank(mutation.companyName())) {
continue;
}
LocalDateTime now = LocalDateTime.now();
if (isBlank(mutation.id())) {
farmerRepository.save(new Farmer(
null,
accountId,
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.phoneNumber()),
mutation.active(),
null,
now,
@@ -156,14 +184,33 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Landwirt-ID fehlt");
Farmer existing = farmerRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Landwirt nicht gefunden"));
boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.email(), blankToNull(mutation.email()));
// 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.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) {
farmerRepository.save(new Farmer(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.name(),
existing.customerNumber(),
existing.companyName(),
existing.contactPerson(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(),
existing.phoneNumber(),
false,
existing.supersedesId(),
existing.createdAt(),
@@ -171,9 +218,17 @@ public class CatalogService {
));
farmerRepository.save(new Farmer(
null,
existing.accountId(),
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.phoneNumber()),
mutation.active(),
existing.id(),
now,
@@ -184,9 +239,17 @@ public class CatalogService {
if (existing.active() != mutation.active()) {
farmerRepository.save(new Farmer(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.name(),
existing.customerNumber(),
existing.companyName(),
existing.contactPerson(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(),
existing.phoneNumber(),
mutation.active(),
existing.supersedesId(),
existing.createdAt(),
@@ -194,11 +257,12 @@ public class CatalogService {
));
}
}
return listFarmerRows();
return listFarmerRowsForActor(actor);
}
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) {
if (isBlank(mutation.name()) || mutation.category() == null) {
continue;
@@ -207,6 +271,7 @@ public class CatalogService {
if (isBlank(mutation.id())) {
medicationRepository.save(new MedicationCatalogItem(
null,
accountId,
UUID.randomUUID().toString(),
mutation.name().trim(),
mutation.category(),
@@ -220,11 +285,16 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Medikament-ID fehlt");
MedicationCatalogItem existing = medicationRepository.findById(mutationId)
.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())
|| existing.category() != mutation.category();
if (changed) {
medicationRepository.save(new MedicationCatalogItem(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.name(),
existing.category(),
@@ -235,6 +305,7 @@ public class CatalogService {
));
medicationRepository.save(new MedicationCatalogItem(
null,
existing.accountId(),
existing.businessKey(),
mutation.name().trim(),
mutation.category(),
@@ -248,6 +319,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) {
medicationRepository.save(new MedicationCatalogItem(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.name(),
existing.category(),
@@ -258,11 +330,12 @@ public class CatalogService {
));
}
}
return listMedicationRows();
return listMedicationRowsForActor(actor);
}
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) {
if (isBlank(mutation.name()) || mutation.kind() == null) {
continue;
@@ -271,6 +344,7 @@ public class CatalogService {
if (isBlank(mutation.id())) {
pathogenRepository.save(new PathogenCatalogItem(
null,
accountId,
UUID.randomUUID().toString(),
blankToNull(mutation.code()),
mutation.name().trim(),
@@ -285,12 +359,17 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Erreger-ID fehlt");
PathogenCatalogItem existing = pathogenRepository.findById(mutationId)
.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())
|| !safeEquals(existing.code(), blankToNull(mutation.code()))
|| existing.kind() != mutation.kind();
if (changed) {
pathogenRepository.save(new PathogenCatalogItem(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.code(),
existing.name(),
@@ -302,6 +381,7 @@ public class CatalogService {
));
pathogenRepository.save(new PathogenCatalogItem(
null,
existing.accountId(),
existing.businessKey(),
blankToNull(mutation.code()),
mutation.name().trim(),
@@ -316,6 +396,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) {
pathogenRepository.save(new PathogenCatalogItem(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.code(),
existing.name(),
@@ -327,11 +408,12 @@ public class CatalogService {
));
}
}
return listPathogenRows();
return listPathogenRowsForActor(actor);
}
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) {
if (isBlank(mutation.name())) {
continue;
@@ -340,6 +422,7 @@ public class CatalogService {
if (isBlank(mutation.id())) {
antibioticRepository.save(new AntibioticCatalogItem(
null,
accountId,
UUID.randomUUID().toString(),
blankToNull(mutation.code()),
mutation.name().trim(),
@@ -353,11 +436,16 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Antibiotika-ID fehlt");
AntibioticCatalogItem existing = antibioticRepository.findById(mutationId)
.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())
|| !safeEquals(existing.code(), blankToNull(mutation.code()));
if (changed) {
antibioticRepository.save(new AntibioticCatalogItem(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.code(),
existing.name(),
@@ -368,6 +456,7 @@ public class CatalogService {
));
antibioticRepository.save(new AntibioticCatalogItem(
null,
existing.accountId(),
existing.businessKey(),
blankToNull(mutation.code()),
mutation.name().trim(),
@@ -381,6 +470,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) {
antibioticRepository.save(new AntibioticCatalogItem(
existing.id(),
existing.accountId(),
existing.businessKey(),
existing.code(),
existing.name(),
@@ -391,7 +481,7 @@ public class CatalogService {
));
}
}
return listAntibioticRows();
return listAntibioticRowsForActor(actor);
}
public List<UserRow> listUsers(String actorId) {
@@ -421,6 +511,7 @@ public class CatalogService {
}
String userId = UUID.randomUUID().toString();
boolean adminManaged = actor.role() == UserRole.ADMIN;
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser(
userId,
adminManaged ? userId : resolveAccountId(actor),
@@ -444,6 +535,7 @@ public class CatalogService {
mutation.active(),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
100000L,
customerNumber,
now,
now
));
@@ -481,6 +573,7 @@ public class CatalogService {
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()),
existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(),
now
));
@@ -532,6 +625,7 @@ public class CatalogService {
existing.active(),
existing.role(),
existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(),
LocalDateTime.now()
));
@@ -578,6 +672,7 @@ public class CatalogService {
String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName;
LocalDateTime now = LocalDateTime.now();
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(),
@@ -600,6 +695,7 @@ public class CatalogService {
false,
UserRole.CUSTOMER,
100000L,
customerNumber,
now,
now
));
@@ -624,6 +720,7 @@ public class CatalogService {
false,
created.role(),
created.nextSampleNumber(),
created.customerNumber(),
created.createdAt(),
created.updatedAt()
));
@@ -645,28 +742,34 @@ public class CatalogService {
removeLegacyUserCodeField();
backfillDefaultUserEmails();
removeLegacyPortalLoginField();
migrateCustomerNumbers();
migrateCatalogAccountIds();
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
}
public Farmer requireActiveFarmer(String businessKey) {
return farmerRepository.findByActiveTrueOrderByNameAsc().stream()
public Farmer requireActiveFarmer(String actorId, String businessKey) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveFarmersForActor(actor).stream()
.filter(farmer -> farmer.businessKey().equals(businessKey))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
}
public Map<String, PathogenCatalogItem> activePathogensByBusinessKey() {
return pathogenRepository.findByActiveTrueOrderByNameAsc().stream()
public Map<String, PathogenCatalogItem> activePathogensByBusinessKey(String actorId) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActivePathogensForActor(actor).stream()
.collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity()));
}
public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey() {
return antibioticRepository.findByActiveTrueOrderByNameAsc().stream()
public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey(String actorId) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveAntibioticsForActor(actor).stream()
.collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity()));
}
public Map<String, MedicationCatalogItem> activeMedicationsByBusinessKey() {
return medicationRepository.findByActiveTrueOrderByNameAsc().stream()
public Map<String, MedicationCatalogItem> activeMedicationsByBusinessKey(String actorId) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveMedicationsForActor(actor).stream()
.collect(Collectors.toMap(MedicationCatalogItem::businessKey, Function.identity()));
}
@@ -674,8 +777,15 @@ public class CatalogService {
return new FarmerRow(
farmer.id(),
farmer.businessKey(),
farmer.name(),
farmer.customerNumber(),
farmer.companyName(),
farmer.contactPerson(),
farmer.street(),
farmer.houseNumber(),
farmer.postalCode(),
farmer.city(),
farmer.email(),
farmer.phoneNumber(),
farmer.active(),
farmer.updatedAt()
);
@@ -734,12 +844,13 @@ public class CatalogService {
user.bic(),
user.active(),
normalizeStoredRole(user.role()),
user.customerNumber(),
user.updatedAt()
);
}
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) {
@@ -771,7 +882,8 @@ public class CatalogService {
user.bankName(),
user.iban(),
user.bic(),
normalizeStoredRole(user.role())
normalizeStoredRole(user.role()),
user.customerNumber()
);
}
@@ -871,11 +983,64 @@ public class CatalogService {
user.active(),
normalizeStoredRole(user.role()),
user.nextSampleNumber(),
user.customerNumber(),
user.createdAt(),
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(
String displayName,
String email,
@@ -910,6 +1075,7 @@ public class CatalogService {
true,
role,
100000L,
generateNextCustomerNumber(),
now,
now
));
@@ -935,6 +1101,93 @@ public class CatalogService {
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) {
mongoTemplate.updateMulti(
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) {
@@ -1037,21 +1290,41 @@ public class CatalogService {
String bankName,
String iban,
String bic,
UserRole role
UserRole role,
String customerNumber
) {
}
public record FarmerRow(
String id,
String businessKey,
String name,
String customerNumber,
String companyName,
String contactPerson,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
boolean active,
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(
@@ -1112,6 +1385,7 @@ public class CatalogService {
String bic,
boolean active,
UserRole role,
String customerNumber,
LocalDateTime updatedAt
) {
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,7 +102,7 @@ public class SampleService {
public SampleDetail createSample(String actorId, RegistrationRequest request) {
AppUser actor = requireActor(actorId);
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()))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
@@ -124,7 +124,7 @@ public class SampleService {
null,
sampleNumber,
farmer.businessKey(),
farmer.name(),
farmer.companyName(),
farmer.email(),
request.cowNumber().trim(),
blankToNull(request.cowName()),
@@ -157,7 +157,7 @@ public class SampleService {
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()))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
@@ -178,7 +178,7 @@ public class SampleService {
existing.id(),
existing.sampleNumber(),
farmer.businessKey(),
farmer.name(),
farmer.companyName(),
farmer.email(),
request.cowNumber().trim(),
blankToNull(request.cowName()),
@@ -220,7 +220,7 @@ public class SampleService {
current.put(quarter.quarterKey(), quarter);
}
Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey();
Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey(actorId);
List<QuarterFinding> updatedQuarters = new ArrayList<>();
for (AnamnesisQuarterRequest quarterRequest : request.quarters()) {
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");
}
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, QuarterFinding> quartersByKey = existing.quarters().stream()
.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");
}
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(
request.continueStarted(),
request.switchTherapy(),
@@ -622,6 +622,7 @@ public class SampleService {
actor.active(),
actor.role(),
sampleNumber + 1,
actor.customerNumber(),
actor.createdAt(),
LocalDateTime.now()
));

View File

@@ -26,30 +26,57 @@ public class CatalogController {
@GetMapping("/catalogs/summary")
public CatalogService.ActiveCatalogSummary catalogSummary() {
return catalogService.activeCatalogSummary();
return catalogService.activeCatalogSummary(securitySupport.currentUser().id());
}
// Legacy admin endpoints - ADMIN only
@GetMapping("/admin")
public CatalogService.AdministrationOverview administrationOverview() {
return catalogService.administrationOverview(securitySupport.currentUser().id());
}
@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);
}
@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);
}
@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);
}
@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) {
return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations);
}

View File

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

View File

@@ -31,6 +31,8 @@ spring:
enable: ${MUH_MAIL_STARTTLS:false}
muh:
app:
version: '@project.version@'
cors:
allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
security:

73
docker_push.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REGISTRY_IMAGE="registry.assecutor.org/muh"
readonly POM_FILE="${SCRIPT_DIR}/backend/pom.xml"
usage() {
cat <<'EOF'
Verwendung:
./docker_push.sh [x.y.z]
Beispiel:
./docker_push.sh 0.9.13
./docker_push.sh
Voraussetzungen:
- Docker Buildx ist installiert
- Login zur Registry wurde bereits ausgeführt:
docker login registry.assecutor.org
Ohne Versionsargument wird automatisch die Version aus backend/pom.xml verwendet.
Optional kann VITE_API_URL als Umgebungsvariable gesetzt werden.
EOF
}
fail() {
echo "Fehler: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "'$1' wurde nicht gefunden."
}
resolve_app_version() {
[[ -f "${POM_FILE}" ]] || fail "'${POM_FILE}' wurde nicht gefunden."
local version
version="$(sed -n '/<parent>/,/<\/parent>/!{ s/.*<version>\(.*\)<\/version>.*/\1/p; }' "${POM_FILE}" | head -1)"
[[ -n "${version}" ]] || fail "Version konnte nicht aus ${POM_FILE} ermittelt werden."
[[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Version in ${POM_FILE} muss das Format x.y.z haben."
echo "${version}"
}
VERSION="${1:-$(resolve_app_version)}"
if [[ "${VERSION}" == "-h" || "${VERSION}" == "--help" ]]; then
usage
exit 0
fi
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
fail "Versionsnummer muss das Format x.y.z haben."
fi
require_command docker
docker buildx version >/dev/null 2>&1 || fail "Docker Buildx ist nicht verfügbar."
cd "${SCRIPT_DIR}"
echo "Verwende Release-Version ${VERSION}."
echo "Pushe Image ${REGISTRY_IMAGE}:${VERSION} ..."
docker buildx build \
--platform linux/amd64 \
--build-arg "VITE_API_URL=${VITE_API_URL:-/api}" \
-t "${REGISTRY_IMAGE}:${VERSION}" \
--push \
.
echo "Fertig: ${REGISTRY_IMAGE}:${VERSION}"

View File

@@ -1 +1,3 @@
declare const __APP_VERSION__: string;
interface Worker {}

View File

@@ -1,6 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSession } from "../lib/session";
import { APP_VERSION } from "../lib/version";
const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite",
@@ -43,7 +42,7 @@ export default function AppShell() {
<aside className="sidebar">
<div className="sidebar__brand">
<div className="sidebar__logo">
MUH <span className="sidebar__version">({APP_VERSION})</span>
MUH <span className="sidebar__version">({__APP_VERSION__})</span>
</div>
</div>

View File

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

View File

@@ -1,14 +0,0 @@
/**
* Application Version
*
* Semantic Versioning: MAJOR.MINOR.PATCH
* - MAJOR: Incompatible API changes
* - MINOR: New functionality (backward compatible)
* - PATCH: Bug fixes (backward compatible)
*/
export const APP_VERSION = "0.8.0";
/**
* Build date - set at build time
*/
export const BUILD_DATE = new Date().toISOString().split('T')[0];

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,14 @@
import { useEffect, useState } from "react";
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 {
id: string;
@@ -15,6 +24,35 @@ interface InvoiceOverview {
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> = {
DRAFT: "Entwurf",
SENT: "Versendet",
@@ -31,11 +69,127 @@ const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
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() {
const { user } = useSession();
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
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(() => {
let cancelled = false;
setLoading(true);
@@ -47,10 +201,8 @@ export default function InvoiceManagementPage() {
setInvoices(response.invoices);
}
})
.catch((err) => {
.catch(() => {
if (!cancelled) {
// Für den Moment zeigen wir einfach eine leere Liste an
// bis das Backend implementiert ist
setInvoices([]);
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) => {
return new Intl.NumberFormat("de-DE", {
style: "currency",
@@ -79,6 +253,59 @@ export default function InvoiceManagementPage() {
}).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 (
<div className="page-stack">
<section className="section-card section-card--hero">
@@ -101,7 +328,11 @@ export default function InvoiceManagementPage() {
<h3>Rechnungsliste</h3>
</div>
<div className="page-actions">
<button type="button" className="accent-button" disabled>
<button
type="button"
className="accent-button"
onClick={handleNewInvoice}
>
Neue Rechnung
</button>
</div>
@@ -113,7 +344,7 @@ export default function InvoiceManagementPage() {
<div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text">
Die Rechnungsverwaltung wird in Kürze verfügbar sein.
Erstellen Sie Ihre erste Rechnung mit dem Button "Neue Rechnung".
</p>
</div>
) : (
@@ -191,6 +422,219 @@ export default function InvoiceManagementPage() {
</table>
</div>
</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>
);
}

View File

@@ -58,10 +58,10 @@ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
"invoice-items-muh",
]);
type ElementKind = "text" | "line" | "image";
type LineOrientation = "horizontal" | "vertical";
type TextAlign = "left" | "center" | "right";
type FontWeight = 400 | 500 | 600 | 700;
export type ElementKind = "text" | "line" | "image";
export type LineOrientation = "horizontal" | "vertical";
export type TextAlign = "left" | "center" | "right";
export type FontWeight = 400 | 500 | 600 | 700;
type PaletteCategory = string;
function isMuhInvoicePaletteId(paletteId?: string) {
@@ -87,7 +87,7 @@ interface PaletteItem {
defaultContent: (user: UserOption | null) => string;
}
interface TemplateElement {
export interface TemplateElement {
id: string;
paletteId: string;
kind: ElementKind;
@@ -1006,7 +1006,7 @@ function createPdfContentStream(
return commands.join("\n");
}
function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
export function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
const imageResources = createPdfImageResources(elements);
const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource]));
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)
.map((row) => `${row.label} | ${row.amount}`)
.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) {
return new Promise<string>((resolve, reject) => {
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)) {
return null;
}

View File

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

View File

@@ -22,7 +22,7 @@ export default function SearchFarmerPage() {
`/portal/snapshot?farmerBusinessKey=${encodeURIComponent(farmer.businessKey)}`,
);
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.");
}
@@ -121,7 +121,7 @@ export default function SearchFarmerPage() {
className="user-card"
onClick={() => void loadFarmerSamples(farmer)}
>
<strong>{farmer.name}</strong>
<strong>{farmer.companyName}</strong>
<small>{farmer.email ?? "ohne E-Mail"}</small>
</button>
))}

View File

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

View File

@@ -570,6 +570,67 @@ a {
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 {
width: 100%;
border-collapse: collapse;
@@ -590,6 +651,62 @@ a {
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,
.info-chip {
display: inline-flex;
@@ -781,6 +898,14 @@ a {
justify-content: flex-end;
}
.user-management-page__create-action {
margin-top: 18px;
}
.user-management-page__profile-header {
margin-bottom: 0;
}
.invoice-template-page {
min-height: 0;
overflow: hidden;
@@ -978,7 +1103,7 @@ a {
position: absolute;
display: grid;
gap: 0;
padding: 4px 5px;
padding: 4px 11px;
border: 1px solid transparent;
border-radius: 16px;
background: transparent;
@@ -1451,6 +1576,10 @@ a {
justify-content: flex-end;
}
.dialog__actions--start {
justify-content: flex-start;
}
.dialog__actions a {
display: inline-flex;
align-items: center;
@@ -1462,6 +1591,11 @@ a {
min-height: 0;
}
.dialog__body--form,
.dialog__body--farmer {
overflow: auto;
}
.dialog__body--pdf {
height: min(80vh, 900px);
}

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,26 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const CONFIG_DIR = path.dirname(fileURLToPath(import.meta.url));
const POM_PATH = path.resolve(CONFIG_DIR, "../backend/pom.xml");
function resolveAppVersion() {
const content = fs.readFileSync(POM_PATH, "utf8");
const parentRange = content.indexOf("<parent>");
const parentEnd = content.indexOf("</parent>");
const withoutParent = content.slice(0, parentRange) + content.slice(parentEnd + "</parent>".length);
const match = withoutParent.match(/<version>(\d+\.\d+\.\d+)<\/version>/);
if (match) {
return match[1];
}
throw new Error(`Version konnte nicht aus ${POM_PATH} ermittelt werden.`);
}
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(resolveAppVersion()),
},
server: {
port: 5173,
host: "0.0.0.0",

View File

@@ -1,8 +1,53 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const CONFIG_DIR = path.dirname(fileURLToPath(import.meta.url));
const APPLICATION_CONFIG_PATH = path.resolve(CONFIG_DIR, "../backend/src/main/resources/application.yml");
function resolveAppVersion(): string {
const lines = fs.readFileSync(APPLICATION_CONFIG_PATH, "utf8").split(/\r?\n/);
let inMuhSection = false;
let inAppSection = false;
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith("#")) {
continue;
}
const indentation = line.length - line.trimStart().length;
if (indentation === 0) {
inMuhSection = trimmedLine === "muh:";
inAppSection = false;
continue;
}
if (inMuhSection && indentation === 2) {
inAppSection = trimmedLine === "app:";
continue;
}
if (inMuhSection && inAppSection && indentation === 4 && trimmedLine.startsWith("version:")) {
const version = trimmedLine.slice("version:".length).trim().replace(/^['"]|['"]$/g, "");
if (/^\d+\.\d+\.\d+$/.test(version)) {
return version;
}
throw new Error(`Ungueltige Versionsnummer in ${APPLICATION_CONFIG_PATH}: ${version}`);
}
}
throw new Error(`muh.app.version konnte nicht aus ${APPLICATION_CONFIG_PATH} ermittelt werden.`);
}
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(resolveAppVersion()),
},
server: {
port: 5173,
host: "0.0.0.0",