Compare commits

..

34 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
58c78bbbbd Rechnungstemplate: Kontoverbindung aus Stammdaten, verbesserte Tabellenformatierung, einspaltige Palette, Delete-Taste zum Löschen, UI-Optimierungen 2026-03-18 16:29:10 +01:00
dc35995e64 feat: Add invoice items element with dynamic pricing from pricing table
- Add new 'invoice-items-muh' element to invoice template
- Add 'Positionen' category back to palette groups
- Create helper functions to format price and generate invoice content
- Add /current endpoint to SystemPricingController for authenticated users
- Load pricing when creating starter layout and display:
  - Monatliche Systemgebühr MUH with net price
  - Umsatzsteuer (19%)
  - Gesamtsumme (brutto)
- Update starter layout positioning for new element
2026-03-18 12:02:38 +01:00
3d9b807261 feat: Remove 'Beträge' section from invoice template
Remove all invoice totals elements:
- invoice-subtotal (Zwischensumme)
- invoice-tax (Mehrwertsteuer)
- invoice-total (Gesamtbetrag)

Remove 'Beträge' category from palette groups.
Adjust positions of remaining elements (payment-terms, bank-details).
2026-03-18 11:57:09 +01:00
f1d60e2109 feat: Split issuer address into separate fields in invoice template
Replace combined 'issuer-address' with 4 separate elements:
- issuer-street (Straße)
- issuer-house-number (Hausnummer)
- issuer-postal-code (PLZ)
- issuer-city (Ort)

Update starter layout to use new separate fields with appropriate positioning.
2026-03-18 11:56:05 +01:00
f7226604e2 feat: Split customer address into separate fields in invoice template
Replace combined 'customer-address' with 4 separate elements:
- customer-street (Straße)
- customer-house-number (Hausnummer)
- customer-postal-code (PLZ)
- customer-city (Ort)

Update starter layout to use new separate fields with appropriate positioning.
2026-03-18 11:51:44 +01:00
4c1dd72659 fix: Correct invoice template API endpoints from /admin to /session
The frontend was using /admin/invoice-template but the backend
endpoints are at /api/session/invoice-template. This caused 404
errors and the misleading message 'Template-Speicherung ist auf
diesem Server nicht verfuegbar'.
2026-03-18 11:47:18 +01:00
532dcb83c1 fix: Add missing bank fields in SampleService AppUser constructor 2026-03-18 10:43:43 +01:00
e43e9c40ad fix: Only allow ADMIN to save bank account details
Change condition from 'isPrimaryUser || isAdmin' to just 'isAdmin'
for bank account fields to ensure only ADMIN users can have/save
bank account data, not regular CUSTOMER users.
2026-03-18 09:33:05 +01:00
60e2f95637 feat: Add bank account details to admin profile
- Add accountHolder, bankName, iban, bic fields to AppUser domain
- Update UserOption, UserRow, UserMutation records in CatalogService
- Update all AppUser constructor calls to include new fields
- Add bank fields to frontend UserOption and UserRow types
- Add bank account form section to AdminProfilePage
2026-03-18 09:31:32 +01:00
8adc817428 feat: Remove 'Steuerhinweis' element from invoice template
Remove the 'Steuerhinweis' (invoice-tax-note) element with the text
'* Alle Preise verstehen sich zzgl. gesetzlicher MwSt.' from:
- INVOICE_PALETTE_ITEMS list
- INVOICE_LOCKED_TEXT_PALETTE_IDS set
- Starter layout and adjust positions of following elements
2026-03-18 09:24:14 +01:00
5fb6f3303b feat: Remove 'Positionen' section from invoice template
Remove the unused invoice items elements from the admin invoice template:
- Remove 'invoice-items-header' and 'invoice-items-rows' palette items
- Remove 'Positionen' category from palette groups
- Remove these elements from the starter layout
- Remove IDs from INVOICE_LOCKED_TEXT_PALETTE_IDS
2026-03-18 09:23:17 +01:00
6dbf5a00c4 fix: Remove false 'Template-API not available' message on 404
When no template was saved yet, the API returns 404. This does not mean
the API is unavailable - it just means no template exists yet. The code
was incorrectly setting isTemplateApiAvailable to false on 404, causing
the misleading message 'Template-API auf diesem Server nicht verfuegbar'.

Fixed in both InvoiceTemplatePage and ReportTemplatePage.
2026-03-18 09:19:54 +01:00
775b09ebeb feat: Add Stammdaten page for admin to manage own profile
- Add AdminProfilePage component for admin to edit own data
- Add 'Stammdaten' menu item in admin navigation
- Add route /admin/stammdaten for the new page
- Use existing POST /api/portal/users endpoint to save changes
- Update session context after successful save
2026-03-18 09:09:57 +01:00
49b1a3b363 feat: Add Preistabelle for admin to manage monthly system price
- Add SystemPricing domain model to store monthly price in MongoDB
- Add SystemPricingRepository for database access
- Add SystemPricingService with get/save functionality
- Add SystemPricingController with GET/POST endpoints (admin only)
- Add PricingPage component for frontend
- Add navigation menu item for Preistabelle (above Rechnung)
- Add route /admin/preistabelle for the new page
2026-03-17 21:18:27 +01:00
571019d34b feat: Move version number to sidebar next to MUH logo
- Add version number in small font next to MUH logo in sidebar
- Remove version footer from HomePage
- Add CSS styling for sidebar version display
2026-03-17 17:46:36 +01:00
3c0335ae7c feat: Add version number to homepage footer
- Create version.ts in lib folder with APP_VERSION constant
- Add version footer to HomePage
- Add CSS styling for version footer
- Starting version: 0.8.0
2026-03-17 17:43:18 +01:00
fdac954cea fix: Disable autocomplete for registration form fields
- Add autoComplete='off' to registration form and email field
- Add autoComplete='new-password' to password fields
- Add autoComplete='off' to phone number field
2026-03-17 17:40:16 +01:00
e315160975 feat: Add password confirmation and profile editing to user management
Frontend:
- Add password confirmation field when creating sub-users
- Add password mismatch validation
- Add 'Meine Stammdaten' form for primary users to edit their profile
- Extend session context with updateUser() function
- Disable autocomplete for sensitive fields
2026-03-17 17:04:45 +01:00
91f67f7dfc feat: Allow primary users to access user management and create sub-users
Frontend:
- Extend UserManagementPage for both ADMIN and primary users
- Admins see all primary users (excluding other admins)
- Primary users see only their sub-users
- Add create sub-user form for primary users
- Adjust UI text based on user role
- Fix table columns (hide company column for primary users)
2026-03-17 16:58:47 +01:00
d03dc94ad1 feat: Extend React/Java app to match Lua functionality
Backend:
- Add Pretreatment record for pre-treatment data
- Extend Sample with pretreatment, clinicalExamDate, internalNote
- Extend TherapyRecommendation with detail fields (count, duration, dosage, location)
- Add startvacVaccination and noAntibioticTreatment flags
- Add null-safety defaults for MongoDB compatibility

Frontend:
- Add pretreatment fields to SampleRegistrationPage
- Add special pathogens section to AnamnesisPage
- Add therapy detail pickers to TherapyPage
- Improve AntibiogramPage: full text labels, centered headers
- Fix AdminDashboardPage TypeScript error in chart tooltip

Styling:
- Enlarge matrix buttons for S/I/R text
- Add matrix-col class for centered table columns
2026-03-17 16:50:40 +01:00
7c59944646 feat: New users require admin approval
- Set active=false for newly registered users
- Return RegistrationResponse instead of SessionResponse after registration
- Show success message informing user that admin approval is pending
- Login check already filters for active users only
2026-03-17 09:28:14 +01:00
3367129d37 fix: Add nextSampleNumber to all AppUser constructor calls in CatalogService 2026-03-17 09:18:53 +01:00
217e0b8dc0 feat: Store next sample number on user collection
- Add nextSampleNumber field to AppUser record with default 100000
- Add compact constructor to initialize nextSampleNumber to 100000
- Add reserveNextSampleNumber method to atomically reserve and increment
- Update createSample to use reserveNextSampleNumber from user
- Update nextSampleNumber to read from user collection
- Update dashboardOverview to use new nextSampleNumber method
2026-03-17 09:10:40 +01:00
dbc8c2a2a2 feat: Start sample numbering at 10000 for each veterinarian
- Add findTopByOwnerAccountIdOrderBySampleNumberDesc to SampleRepository
- Modify nextSampleNumber to accept ownerAccountId parameter
- Calculate next sample number per user based on ownerAccountId
- Update dashboardOverview and createSample to use user-specific numbering
2026-03-17 09:04:41 +01:00
93f52f1ae1 style: Display invoice statistics in a table layout
- Replace stat cards with table layout
- Keep consistent text size with rest of UI
- Align values to the right for better readability
2026-03-17 09:00:19 +01:00
b9919828e4 Revert "style: Improve invoice statistics layout with vertical spacing"
This reverts commit ce76a29038.
2026-03-17 08:59:53 +01:00
ce76a29038 style: Improve invoice statistics layout with vertical spacing
- Change stats grid to vertical list layout
- Add more spacing between stat cards
- Display value and label horizontally aligned with proper gap
2026-03-17 08:59:08 +01:00
58 changed files with 5005 additions and 679 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

@@ -105,3 +105,9 @@ Kundenregistrierung:
- `cd backend && mvn test`
- `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;
@@ -19,10 +20,21 @@ public record AppUser(
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String passwordHash,
boolean active,
UserRole role,
Long nextSampleNumber,
@Indexed(unique = true) String customerNumber,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public AppUser {
if (nextSampleNumber == null) {
nextSampleNumber = 100000L;
}
}
}

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

@@ -0,0 +1,9 @@
package de.svencarstensen.muh.domain;
public record Pretreatment(
String inUdderInjector,
String systemicAntibiotics,
String painMedication,
String dryOffTreatment
) {
}

View File

@@ -3,6 +3,7 @@ package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -29,6 +30,10 @@ public record Sample(
LocalDateTime completedAt,
String ownerAccountId,
String createdByUserCode,
String createdByDisplayName
String createdByDisplayName,
// Additional fields from Lua version
Pretreatment pretreatment,
LocalDate clinicalExamDate,
String internalNote
) {
}

View File

@@ -0,0 +1,15 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document("systemPricing")
public record SystemPricing(
@Id String id,
Double monthlyPrice,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -16,6 +16,15 @@ public record TherapyRecommendation(
List<String> dryAntibioticKeys,
List<String> dryAntibioticNames,
String farmerNote,
String internalNote
String internalNote,
// Additional fields from Lua version
String inUdderCount,
String inUdderDuration,
String systemicCount,
String systemicDuration,
String systemicDosage,
String systemicLocation,
Boolean startvacVaccination,
Boolean noAntibioticTreatment
) {
}

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

@@ -12,6 +12,8 @@ public interface SampleRepository extends MongoRepository<Sample, String> {
Optional<Sample> findTopByOrderBySampleNumberDesc();
Optional<Sample> findTopByOwnerAccountIdOrderBySampleNumberDesc(String ownerAccountId);
List<Sample> findTop12ByOrderByUpdatedAtDesc();
List<Sample> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey);

View File

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

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),
@@ -436,9 +527,15 @@ public class CatalogService {
adminManaged ? blankToNull(mutation.city()) : null,
normalizeEmail(mutation.email()),
adminManaged ? blankToNull(mutation.phoneNumber()) : null,
adminManaged ? blankToNull(mutation.accountHolder()) : null,
adminManaged ? blankToNull(mutation.bankName()) : null,
adminManaged ? blankToNull(mutation.iban()) : null,
adminManaged ? blankToNull(mutation.bic()) : null,
encodeIfPresent(mutation.password()),
mutation.active(),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
100000L,
customerNumber,
now,
now
));
@@ -466,11 +563,17 @@ public class CatalogService {
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.city()) : existing.city(),
normalizeEmail(mutation.email()),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.phoneNumber()) : existing.phoneNumber(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.accountHolder()) : existing.accountHolder(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.bankName()) : existing.bankName(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.iban()) : existing.iban(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.bic()) : existing.bic(),
isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(),
actor.role() == UserRole.ADMIN
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()),
existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(),
now
));
@@ -514,9 +617,15 @@ public class CatalogService {
existing.city(),
existing.email(),
existing.phoneNumber(),
existing.accountHolder(),
existing.bankName(),
existing.iban(),
existing.bic(),
passwordEncoder.encode(newPassword),
existing.active(),
existing.role(),
existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(),
LocalDateTime.now()
));
@@ -534,7 +643,7 @@ public class CatalogService {
return toSessionResponse(user);
}
public SessionResponse registerCustomer(RegistrationMutation mutation) {
public RegistrationResponse registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName())
|| isBlank(mutation.street())
|| isBlank(mutation.houseNumber())
@@ -563,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(),
@@ -577,9 +687,15 @@ public class CatalogService {
city,
normalizedEmail,
phoneNumber,
null,
null,
null,
null,
passwordEncoder.encode(mutation.password()),
true,
false,
UserRole.CUSTOMER,
100000L,
customerNumber,
now,
now
));
@@ -596,13 +712,22 @@ public class CatalogService {
created.city(),
created.email(),
created.phoneNumber(),
created.accountHolder(),
created.bankName(),
created.iban(),
created.bic(),
created.passwordHash(),
created.active(),
false,
created.role(),
created.nextSampleNumber(),
created.customerNumber(),
created.createdAt(),
created.updatedAt()
));
return toSessionResponse(accountBound);
return new RegistrationResponse(accountBound.id(), accountBound.email());
}
public record RegistrationResponse(String userId, String email) {
}
public UserOption currentUser(String actorId) {
@@ -617,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()));
}
@@ -646,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()
);
@@ -700,14 +838,19 @@ public class CatalogService {
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
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) {
@@ -735,7 +878,12 @@ public class CatalogService {
user.city(),
user.email(),
user.phoneNumber(),
normalizeStoredRole(user.role())
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
normalizeStoredRole(user.role()),
user.customerNumber()
);
}
@@ -827,14 +975,72 @@ public class CatalogService {
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.passwordHash(),
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,
@@ -861,9 +1067,15 @@ public class CatalogService {
null,
email,
null,
null,
null,
null,
null,
passwordEncoder.encode(rawPassword),
true,
role,
100000L,
generateNextCustomerNumber(),
now,
now
));
@@ -889,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(
@@ -963,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) {
@@ -987,21 +1286,45 @@ public class CatalogService {
String city,
String email,
String phoneNumber,
UserRole role
String accountHolder,
String bankName,
String iban,
String bic,
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(
@@ -1056,8 +1379,13 @@ public class CatalogService {
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
boolean active,
UserRole role,
String customerNumber,
LocalDateTime updatedAt
) {
}
@@ -1073,6 +1401,10 @@ public class CatalogService {
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String password,
boolean active,
UserRole role

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

@@ -4,6 +4,7 @@ import de.svencarstensen.muh.domain.AntibiogramEntry;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.Pretreatment;
import de.svencarstensen.muh.domain.QuarterAntibiogram;
import de.svencarstensen.muh.domain.QuarterFinding;
import de.svencarstensen.muh.domain.QuarterKey;
@@ -22,6 +23,8 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -67,7 +70,7 @@ public class SampleService {
.filter(sample -> !sample.completedAt().isBefore(today.atStartOfDay()))
.filter(sample -> sample.completedAt().isBefore(today.plusDays(1).atStartOfDay()))
.count();
return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent);
return new DashboardOverview(nextSampleNumber(actorId), openCount, completedToday, recent);
}
public LookupResult lookup(String actorId, long sampleNumber) {
@@ -99,16 +102,29 @@ 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"));
long sampleNumber = reserveNextSampleNumber(actorId);
Pretreatment pretreatment = request.pretreatmentInUdderInjector() == null &&
request.pretreatmentSystemicAntibiotics() == null &&
request.pretreatmentPainMedication() == null &&
request.pretreatmentDryOffTreatment() == null
? null
: new Pretreatment(
blankToNull(request.pretreatmentInUdderInjector()),
blankToNull(request.pretreatmentSystemicAntibiotics()),
blankToNull(request.pretreatmentPainMedication()),
blankToNull(request.pretreatmentDryOffTreatment())
);
Sample sample = new Sample(
null,
nextSampleNumber(),
sampleNumber,
farmer.businessKey(),
farmer.name(),
farmer.companyName(),
farmer.email(),
request.cowNumber().trim(),
blankToNull(request.cowName()),
@@ -126,7 +142,10 @@ public class SampleService {
null,
authorizationService.accountId(actor),
request.userCode(),
request.userDisplayName()
request.userDisplayName(),
pretreatment,
parseClinicalExamDate(request.clinicalExamDate()),
blankToNull(request.internalNote())
);
return toDetail(sampleRepository.save(sample));
@@ -138,16 +157,28 @@ 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"));
Pretreatment pretreatment = request.pretreatmentInUdderInjector() == null &&
request.pretreatmentSystemicAntibiotics() == null &&
request.pretreatmentPainMedication() == null &&
request.pretreatmentDryOffTreatment() == null
? existing.pretreatment()
: new Pretreatment(
blankToNull(request.pretreatmentInUdderInjector()),
blankToNull(request.pretreatmentSystemicAntibiotics()),
blankToNull(request.pretreatmentPainMedication()),
blankToNull(request.pretreatmentDryOffTreatment())
);
Sample saved = sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
farmer.businessKey(),
farmer.name(),
farmer.companyName(),
farmer.email(),
request.cowNumber().trim(),
blankToNull(request.cowName()),
@@ -165,7 +196,14 @@ public class SampleService {
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
existing.createdByDisplayName(),
pretreatment,
parseClinicalExamDate(request.clinicalExamDate()) != null
? parseClinicalExamDate(request.clinicalExamDate())
: existing.clinicalExamDate(),
request.internalNote() != null
? blankToNull(request.internalNote())
: existing.internalNote()
));
return toDetail(saved);
@@ -182,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());
@@ -232,7 +270,10 @@ public class SampleService {
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
));
return toDetail(saved);
}
@@ -243,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));
@@ -323,7 +364,10 @@ public class SampleService {
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
));
return toDetail(saved);
}
@@ -333,7 +377,7 @@ public class SampleService {
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
TherapyRecommendation previous = existing.therapyRecommendation();
TherapyRecommendation updated = previous == null
? new TherapyRecommendation(false, false, List.of(), List.of(), null, List.of(), List.of(), null, List.of(), List.of(), List.of(), List.of(), null, blankToNull(request.internalNote()))
? new TherapyRecommendation(false, false, List.of(), List.of(), null, List.of(), List.of(), null, List.of(), List.of(), List.of(), List.of(), null, blankToNull(request.internalNote()), null, null, null, null, null, null, Boolean.FALSE, Boolean.FALSE)
: new TherapyRecommendation(
previous.continueStarted(),
previous.switchTherapy(),
@@ -348,7 +392,15 @@ public class SampleService {
previous.dryAntibioticKeys(),
previous.dryAntibioticNames(),
previous.farmerNote(),
blankToNull(request.internalNote())
blankToNull(request.internalNote()),
previous.inUdderCount(),
previous.inUdderDuration(),
previous.systemicCount(),
previous.systemicDuration(),
previous.systemicDosage(),
previous.systemicLocation(),
previous.startvacVaccination() != null ? previous.startvacVaccination() : Boolean.FALSE,
previous.noAntibioticTreatment() != null ? previous.noAntibioticTreatment() : Boolean.FALSE
);
return toDetail(sampleRepository.save(new Sample(
existing.id(),
@@ -372,7 +424,10 @@ public class SampleService {
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)));
}
@@ -380,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(),
@@ -395,7 +450,15 @@ public class SampleService {
request.dryAntibioticKeys(),
resolveMedicationNames(request.dryAntibioticKeys(), medications),
blankToNull(request.farmerNote()),
blankToNull(request.internalNote())
blankToNull(request.internalNote()),
blankToNull(request.inUdderCount()),
blankToNull(request.inUdderDuration()),
blankToNull(request.systemicCount()),
blankToNull(request.systemicDuration()),
blankToNull(request.systemicDosage()),
blankToNull(request.systemicLocation()),
request.startvacVaccination(),
request.noAntibioticTreatment()
);
Sample saved = sampleRepository.save(new Sample(
@@ -420,7 +483,10 @@ public class SampleService {
LocalDateTime.now(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
));
return toDetail(saved);
}
@@ -449,7 +515,10 @@ public class SampleService {
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
));
}
@@ -477,7 +546,10 @@ public class SampleService {
existing.completedAt(),
existing.ownerAccountId(),
existing.createdByUserCode(),
existing.createdByDisplayName()
existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
));
}
@@ -513,9 +585,49 @@ public class SampleService {
}
public long nextSampleNumber() {
return sampleRepository.findTopByOrderBySampleNumberDesc()
.map(sample -> sample.sampleNumber() + 1)
.orElse(100001L);
return nextSampleNumber(null);
}
public long nextSampleNumber(String actorId) {
if (actorId == null) {
return 100000L;
}
AppUser actor = requireActor(actorId);
return actor.nextSampleNumber() != null ? actor.nextSampleNumber() : 100000L;
}
public long reserveNextSampleNumber(String actorId) {
AppUser actor = requireActor(actorId);
long sampleNumber = actor.nextSampleNumber() != null ? actor.nextSampleNumber() : 100000L;
// Update user with next sample number
appUserRepository.save(new AppUser(
actor.id(),
actor.accountId(),
actor.primaryUser(),
actor.displayName(),
actor.companyName(),
actor.address(),
actor.street(),
actor.houseNumber(),
actor.postalCode(),
actor.city(),
actor.email(),
actor.phoneNumber(),
actor.accountHolder(),
actor.bankName(),
actor.iban(),
actor.bic(),
actor.passwordHash(),
actor.active(),
actor.role(),
sampleNumber + 1,
actor.customerNumber(),
actor.createdAt(),
LocalDateTime.now()
));
return sampleNumber;
}
public Sample loadSampleEntity(String actorId, String id) {
@@ -607,7 +719,10 @@ public class SampleService {
sample.completedAt(),
resolvedAccountId,
sample.createdByUserCode(),
sample.createdByDisplayName()
sample.createdByDisplayName(),
sample.pretreatment(),
sample.clinicalExamDate(),
sample.internalNote()
));
}
}
@@ -701,7 +816,10 @@ public class SampleService {
SampleWorkflowRules.canEditAnamnesis(sample),
SampleWorkflowRules.canEditAntibiogram(sample),
SampleWorkflowRules.canEditTherapy(sample),
sample.currentStep() == SampleWorkflowStep.COMPLETED
sample.currentStep() == SampleWorkflowStep.COMPLETED,
sample.pretreatment(),
sample.clinicalExamDate(),
sample.internalNote()
);
}
@@ -723,7 +841,15 @@ public class SampleService {
therapy.dryAntibioticKeys(),
therapy.dryAntibioticNames(),
therapy.farmerNote(),
therapy.internalNote()
therapy.internalNote(),
therapy.inUdderCount(),
therapy.inUdderDuration(),
therapy.systemicCount(),
therapy.systemicDuration(),
therapy.systemicDosage(),
therapy.systemicLocation(),
therapy.startvacVaccination() != null ? therapy.startvacVaccination() : Boolean.FALSE,
therapy.noAntibioticTreatment() != null ? therapy.noAntibioticTreatment() : Boolean.FALSE
);
}
@@ -841,7 +967,15 @@ public class SampleService {
List<String> dryAntibioticKeys,
List<String> dryAntibioticNames,
String farmerNote,
String internalNote
String internalNote,
String inUdderCount,
String inUdderDuration,
String systemicCount,
String systemicDuration,
String systemicDosage,
String systemicLocation,
Boolean startvacVaccination,
Boolean noAntibioticTreatment
) {
}
@@ -873,7 +1007,10 @@ public class SampleService {
boolean anamnesisEditable,
boolean antibiogramEditable,
boolean therapyEditable,
boolean completed
boolean completed,
Pretreatment pretreatment,
LocalDate clinicalExamDate,
String internalNote
) {
}
@@ -885,7 +1022,13 @@ public class SampleService {
SamplingMode samplingMode,
List<QuarterKey> flaggedQuarters,
String userCode,
String userDisplayName
String userDisplayName,
String pretreatmentInUdderInjector,
String pretreatmentSystemicAntibiotics,
String pretreatmentPainMedication,
String pretreatmentDryOffTreatment,
String clinicalExamDate,
String internalNote
) {
}
@@ -919,7 +1062,33 @@ public class SampleService {
List<String> drySealerKeys,
List<String> dryAntibioticKeys,
String farmerNote,
String internalNote
String internalNote,
String inUdderCount,
String inUdderDuration,
String systemicCount,
String systemicDuration,
String systemicDosage,
String systemicLocation,
Boolean startvacVaccination,
Boolean noAntibioticTreatment
) {
}
private LocalDate parseClinicalExamDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) {
return null;
}
try {
// Try ISO format first (YYYY-MM-DD)
return LocalDate.parse(dateStr);
} catch (DateTimeParseException e) {
try {
// Try German format (DD.MM.YYYY)
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy");
return LocalDate.parse(dateStr, formatter);
} catch (DateTimeParseException e2) {
return null;
}
}
}
}

View File

@@ -0,0 +1,38 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.SystemPricing;
import de.svencarstensen.muh.repository.SystemPricingRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
public class SystemPricingService {
private static final String PRICING_ID = "default";
private final SystemPricingRepository systemPricingRepository;
public SystemPricingService(SystemPricingRepository systemPricingRepository) {
this.systemPricingRepository = systemPricingRepository;
}
public Optional<SystemPricing> getCurrentPricing() {
return systemPricingRepository.findById(PRICING_ID);
}
public SystemPricing savePricing(Double monthlyPrice) {
LocalDateTime now = LocalDateTime.now();
Optional<SystemPricing> existing = systemPricingRepository.findById(PRICING_ID);
SystemPricing pricing = new SystemPricing(
PRICING_ID,
monthlyPrice,
existing.map(SystemPricing::createdAt).orElse(now),
now
);
return systemPricingRepository.save(pricing);
}
}

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

@@ -54,7 +54,13 @@ public class SampleController {
request.samplingMode(),
request.flaggedQuarters(),
deriveUserLabel(user.displayName()),
user.displayName()
user.displayName(),
request.pretreatmentInUdderInjector(),
request.pretreatmentSystemicAntibiotics(),
request.pretreatmentPainMedication(),
request.pretreatmentDryOffTreatment(),
request.clinicalExamDate(),
request.internalNote()
));
}

View File

@@ -56,7 +56,7 @@ public class SessionController {
}
@PostMapping("/register")
public CatalogService.SessionResponse register(@RequestBody RegistrationRequest request) {
public CatalogService.RegistrationResponse register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(),
request.street(),

View File

@@ -0,0 +1,48 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.domain.SystemPricing;
import de.svencarstensen.muh.service.SystemPricingService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/api/admin/pricing")
public class SystemPricingController {
private final SystemPricingService systemPricingService;
public SystemPricingController(SystemPricingService systemPricingService) {
this.systemPricingService = systemPricingService;
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public PricingResponse getPricing() {
Optional<SystemPricing> pricing = systemPricingService.getCurrentPricing();
return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString()))
.orElseGet(() -> new PricingResponse(null, null));
}
@GetMapping("/current")
@PreAuthorize("isAuthenticated()")
public PricingResponse getCurrentPricing() {
Optional<SystemPricing> pricing = systemPricingService.getCurrentPricing();
return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString()))
.orElseGet(() -> new PricingResponse(null, null));
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public PricingResponse savePricing(@RequestBody PricingRequest request) {
SystemPricing saved = systemPricingService.savePricing(request.monthlyPrice());
return new PricingResponse(saved.monthlyPrice(), saved.updatedAt().toString());
}
public record PricingRequest(Double monthlyPrice) {
}
public record PricingResponse(Double monthlyPrice, String updatedAt) {
}
}

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

@@ -17,6 +17,8 @@ import UserManagementPage from "./pages/UserManagementPage";
import ReportTemplatePage from "./pages/ReportTemplatePage";
import InvoiceTemplatePage from "./pages/InvoiceTemplatePage";
import InvoiceManagementPage from "./pages/InvoiceManagementPage";
import PricingPage from "./pages/PricingPage";
import AdminProfilePage from "./pages/AdminProfilePage";
function ProtectedRoutes() {
const { user, ready } = useSession();
@@ -47,6 +49,8 @@ function ProtectedRoutes() {
<Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} />
<Route path="/admin/antibiogramm" element={<AdministrationPage />} />
<Route path="/admin/stammdaten" element={<AdminProfilePage />} />
<Route path="/admin/preistabelle" element={<PricingPage />} />
<Route path="/admin/rechnung/verwalten" element={<InvoiceManagementPage />} />
<Route path="/admin/rechnung/template" element={<InvoiceTemplatePage />} />
<Route path="/search" element={<Navigate to="/search/probe" replace />} />

View File

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

View File

@@ -7,6 +7,8 @@ const PAGE_TITLES: Record<string, string> = {
"/samples/new": "Neuanlage einer Probe",
"/portal": "MUH-Portal",
"/report-template": "Bericht",
"/admin/stammdaten": "Meine Stammdaten",
"/admin/preistabelle": "Preistabelle",
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
"/admin/rechnung/template": "Rechnungsvorlage",
};
@@ -39,7 +41,9 @@ export default function AppShell() {
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar__brand">
<div className="sidebar__logo">MUH</div>
<div className="sidebar__logo">
MUH <span className="sidebar__version">({__APP_VERSION__})</span>
</div>
</div>
<nav className="sidebar__nav">
@@ -61,6 +65,20 @@ export default function AppShell() {
</div>
</div>
<NavLink
to="/admin/stammdaten"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Stammdaten
</NavLink>
<NavLink
to="/admin/preistabelle"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Preistabelle
</NavLink>
<div className="nav-group">
<div className="nav-group__label">Rechnung</div>
<div className="nav-subnav">

View File

@@ -14,12 +14,14 @@ interface SessionContextValue {
user: UserOption | null;
ready: boolean;
setSession: (session: SessionResponse | null) => void;
updateUser: (user: UserOption) => void;
}
const SessionContext = createContext<SessionContextValue>({
user: null,
ready: false,
setSession: () => undefined,
updateUser: () => undefined,
});
function loadStoredUser(): UserOption | null {
@@ -91,11 +93,16 @@ export function SessionProvider({ children }: PropsWithChildren) {
setReady(true);
}
function updateUser(updatedUser: UserOption) {
setUserState(updatedUser);
}
const value = useMemo(
() => ({
user,
ready,
setSession,
updateUser,
}),
[ready, user],
);

View File

@@ -9,6 +9,13 @@ export type QuarterKey =
| "LEFT_REAR"
| "RIGHT_REAR";
export type PathogenKind = "BACTERIAL" | "NO_GROWTH" | "CONTAMINATED" | "OTHER";
export interface Pretreatment {
inUdderInjector: string | null;
systemicAntibiotics: string | null;
painMedication: string | null;
dryOffTreatment: string | null;
}
export type SensitivityResult = "SENSITIVE" | "INTERMEDIATE" | "RESISTANT";
export type MedicationCategory =
| "IN_UDDER"
@@ -20,7 +27,8 @@ export type UserRole = "ADMIN" | "CUSTOMER";
export interface FarmerOption {
businessKey: string;
name: string;
companyName: string;
contactPerson: string | null;
email: string | null;
}
@@ -55,7 +63,12 @@ export interface UserOption {
city: string | null;
email: string | null;
phoneNumber: string | null;
accountHolder: string | null;
bankName: string | null;
iban: string | null;
bic: string | null;
role: UserRole;
customerNumber: string | null;
}
export interface SessionResponse {
@@ -145,6 +158,14 @@ export interface TherapyView {
dryAntibioticNames: string[];
farmerNote: string | null;
internalNote: string | null;
inUdderCount: string | null;
inUdderDuration: string | null;
systemicCount: string | null;
systemicDuration: string | null;
systemicDosage: string | null;
systemicLocation: string | null;
startvacVaccination: boolean | null;
noAntibioticTreatment: boolean | null;
}
export interface SampleDetail {
@@ -176,13 +197,23 @@ export interface SampleDetail {
antibiogramEditable: boolean;
therapyEditable: boolean;
completed: boolean;
pretreatment: Pretreatment | null;
clinicalExamDate: string | null;
internalNote: string | null;
}
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

@@ -7,6 +7,7 @@ import {
Title,
Tooltip,
Legend,
type TooltipItem,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { apiGet } from "../lib/api";
@@ -101,8 +102,9 @@ export default function AdminDashboardPage() {
size: 13,
},
callbacks: {
label: (context: { parsed: { y: number } }) => {
return `${context.parsed.y} Proben`;
label: (context: TooltipItem<"bar">) => {
const value = context.parsed.y as number | null;
return `${value ?? 0} Proben`;
},
},
},

View File

@@ -0,0 +1,365 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserRow } from "../lib/types";
export default function AdminProfilePage() {
const { user, updateUser } = useSession();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [formData, setFormData] = useState({
displayName: "",
companyName: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
accountHolder: "",
bankName: "",
iban: "",
bic: "",
});
// Load current user data
useEffect(() => {
async function loadUserData() {
try {
const users = await apiGet<UserRow[]>("/portal/users");
const currentUser = users.find((u) => u.id === user?.id);
if (currentUser) {
setFormData({
displayName: currentUser.displayName || "",
companyName: currentUser.companyName || "",
street: currentUser.street || "",
houseNumber: currentUser.houseNumber || "",
postalCode: currentUser.postalCode || "",
city: currentUser.city || "",
email: currentUser.email || "",
phoneNumber: currentUser.phoneNumber || "",
accountHolder: currentUser.accountHolder || "",
bankName: currentUser.bankName || "",
iban: currentUser.iban || "",
bic: currentUser.bic || "",
});
}
} catch (error) {
setMessage((error as Error).message);
} finally {
setLoading(false);
}
}
if (user?.id) {
void loadUserData();
}
}, [user?.id]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!formData.displayName.trim()) {
setMessage("Name ist erforderlich.");
return;
}
if (!formData.email.trim()) {
setMessage("E-Mail ist erforderlich.");
return;
}
setSaving(true);
setMessage(null);
try {
const response = await apiPost<UserRow>("/portal/users", {
id: user?.id,
displayName: formData.displayName.trim(),
companyName: formData.companyName.trim() || null,
street: formData.street.trim() || null,
houseNumber: formData.houseNumber.trim() || null,
postalCode: formData.postalCode.trim() || null,
city: formData.city.trim() || null,
email: formData.email.trim(),
phoneNumber: formData.phoneNumber.trim() || null,
accountHolder: formData.accountHolder.trim() || null,
bankName: formData.bankName.trim() || null,
iban: formData.iban.trim() || null,
bic: formData.bic.trim() || null,
active: true,
});
// Update session user
if (updateUser && user) {
updateUser({
...user,
displayName: response.displayName,
companyName: response.companyName,
street: response.street,
houseNumber: response.houseNumber,
postalCode: response.postalCode,
city: response.city,
email: response.email,
phoneNumber: response.phoneNumber,
accountHolder: response.accountHolder,
bankName: response.bankName,
iban: response.iban,
bic: response.bic,
});
}
setMessage("Stammdaten wurden erfolgreich gespeichert.");
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="page-stack">
<section className="section-card">
<div className="empty-state">Stammdaten werden geladen...</div>
</section>
</div>
);
}
return (
<div className="page-stack">
{/* Header */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Stammdaten</p>
<h3>Meine Stammdaten</h3>
<p className="muted-text">
Verwalten Sie hier Ihre persönlichen, Unternehmens- und Bankdaten.
</p>
</div>
</section>
{/* Status-Meldung */}
{message && (
<div
className={
message.includes("erfolgreich")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
)}
{/* Persönliche Daten */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Profil</p>
<h3>Persönliche Daten</h3>
</div>
</div>
<form onSubmit={handleSubmit} className="field-grid field-grid--2col">
<label className="field">
<span>Name *</span>
<input
type="text"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
placeholder="Ihr Name"
required
disabled={saving}
/>
</label>
<label className="field">
<span>Firma</span>
<input
type="text"
value={formData.companyName}
onChange={(e) =>
setFormData({ ...formData, companyName: e.target.value })
}
placeholder="Firmenname"
disabled={saving}
/>
</label>
<label className="field">
<span>Straße</span>
<input
type="text"
value={formData.street}
onChange={(e) =>
setFormData({ ...formData, street: e.target.value })
}
placeholder="Straße"
disabled={saving}
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
type="text"
value={formData.houseNumber}
onChange={(e) =>
setFormData({ ...formData, houseNumber: e.target.value })
}
placeholder="Hausnummer"
disabled={saving}
/>
</label>
<label className="field">
<span>PLZ</span>
<input
type="text"
value={formData.postalCode}
onChange={(e) =>
setFormData({ ...formData, postalCode: e.target.value })
}
placeholder="Postleitzahl"
disabled={saving}
/>
</label>
<label className="field">
<span>Ort</span>
<input
type="text"
value={formData.city}
onChange={(e) =>
setFormData({ ...formData, city: e.target.value })
}
placeholder="Ort"
disabled={saving}
/>
</label>
<label className="field">
<span>E-Mail *</span>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
placeholder="email@beispiel.de"
required
disabled={saving}
/>
</label>
<label className="field">
<span>Telefon</span>
<input
type="tel"
value={formData.phoneNumber}
onChange={(e) =>
setFormData({ ...formData, phoneNumber: e.target.value })
}
placeholder="Telefonnummer"
disabled={saving}
/>
</label>
</form>
</section>
{/* Bankverbindung */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Zahlung</p>
<h3>Bankverbindung</h3>
</div>
</div>
<div className="field-grid field-grid--2col">
<label className="field">
<span>Kontoinhaber</span>
<input
type="text"
value={formData.accountHolder}
onChange={(e) =>
setFormData({ ...formData, accountHolder: e.target.value })
}
placeholder="Name des Kontoinhabers"
disabled={saving}
/>
</label>
<label className="field">
<span>Bankname</span>
<input
type="text"
value={formData.bankName}
onChange={(e) =>
setFormData({ ...formData, bankName: e.target.value })
}
placeholder="Name der Bank"
disabled={saving}
/>
</label>
<label className="field">
<span>IBAN</span>
<input
type="text"
value={formData.iban}
onChange={(e) =>
setFormData({ ...formData, iban: e.target.value })
}
placeholder="DE12 3456 7890 1234 5678 90"
disabled={saving}
/>
</label>
<label className="field">
<span>BIC</span>
<input
type="text"
value={formData.bic}
onChange={(e) =>
setFormData({ ...formData, bic: e.target.value })
}
placeholder="ABCDEFGHXXX"
disabled={saving}
/>
</label>
</div>
<div className="field" style={{ marginTop: "1rem" }}>
<button
type="button"
className="accent-button"
onClick={handleSubmit}
disabled={saving}
>
{saving ? "Wird gespeichert..." : "Stammdaten speichern"}
</button>
</div>
</section>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Ihre Stammdaten werden für die Rechnungsstellung und
Kommunikation verwendet. Stellen Sie sicher, dass die
Daten aktuell und vollständig sind.
</p>
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { apiGet, apiPut } from "../lib/api";
import type { ActiveCatalogSummary, QuarterKey, QuarterView, SampleDetail } from "../lib/types";
import type { ActiveCatalogSummary, PathogenKind, QuarterKey, QuarterView, SampleDetail } from "../lib/types";
type QuarterFormState = {
pathogenBusinessKey: string;
customPathogenName: string;
cellCount: string;
pathogenKind: PathogenKind | null;
};
function quarterStateFromSample(sample: SampleDetail) {
@@ -15,11 +16,18 @@ function quarterStateFromSample(sample: SampleDetail) {
pathogenBusinessKey: quarter.pathogenBusinessKey ?? "",
customPathogenName: quarter.customPathogenName ?? "",
cellCount: quarter.cellCount ? String(quarter.cellCount) : "",
pathogenKind: quarter.pathogenKind,
};
return accumulator;
}, {});
}
// Special pathogen options like in Lua
const SPECIAL_PATHOGENS = [
{ key: "NO_GROWTH", label: "Kein bakterielles Wachstum", kind: "NO_GROWTH" as PathogenKind },
{ key: "CONTAMINATED", label: "Verunreinigte Probe", kind: "CONTAMINATED" as PathogenKind },
];
export default function AnamnesisPage() {
const { sampleId } = useParams();
const navigate = useNavigate();
@@ -76,6 +84,20 @@ export default function AnamnesisPage() {
return Boolean(quarterState?.pathogenBusinessKey || quarterState?.customPathogenName?.trim());
}
function selectSpecialPathogen(quarterKey: QuarterKey, kind: PathogenKind) {
updateQuarter(quarterKey, {
pathogenBusinessKey: "",
customPathogenName: "",
pathogenKind: kind,
});
}
function isSpecialPathogenSelected(quarterKey: QuarterKey, kind: PathogenKind) {
return quarterStates[quarterKey]?.pathogenKind === kind &&
!quarterStates[quarterKey]?.pathogenBusinessKey &&
!quarterStates[quarterKey]?.customPathogenName;
}
async function handleSave() {
if (!sampleId || !sample) {
return;
@@ -117,6 +139,7 @@ export default function AnamnesisPage() {
pathogenBusinessKey: "",
customPathogenName: "",
cellCount: "",
pathogenKind: null,
};
return (
@@ -168,7 +191,25 @@ export default function AnamnesisPage() {
<div className="info-chip">Auffaelliges Viertel markiert</div>
) : null}
<p className="required-label">Erreger</p>
{/* Special pathogen options like in Lua */}
<p className="required-label">Sonderfaelle</p>
<div className="pathogen-grid">
{SPECIAL_PATHOGENS.map((pathogen) => (
<button
key={pathogen.key}
type="button"
className={`pathogen-button ${
isSpecialPathogenSelected(visibleQuarter.quarterKey, pathogen.kind) ? "is-selected" : ""
}`}
onClick={() => selectSpecialPathogen(visibleQuarter.quarterKey, pathogen.kind)}
disabled={!sample.anamnesisEditable}
>
<strong>{pathogen.label}</strong>
</button>
))}
</div>
<p className="required-label section-card__spacer">Erreger (Katalog)</p>
<div className={`pathogen-grid ${showValidation && !quarterHasPathogen(visibleQuarter.quarterKey) ? "is-invalid" : ""}`}>
{catalogs.pathogens.map((pathogen) => (
<button
@@ -181,6 +222,7 @@ export default function AnamnesisPage() {
updateQuarter(visibleQuarter.quarterKey, {
pathogenBusinessKey: pathogen.businessKey,
customPathogenName: "",
pathogenKind: null,
})
}
disabled={!sample.anamnesisEditable}
@@ -199,6 +241,7 @@ export default function AnamnesisPage() {
updateQuarter(visibleQuarter.quarterKey, {
customPathogenName: event.target.value,
pathogenBusinessKey: "",
pathogenKind: null,
})
}
disabled={!sample.anamnesisEditable}
@@ -221,8 +264,8 @@ export default function AnamnesisPage() {
<div className="info-panel info-panel--spaced">
<strong>Hinweis</strong>
<p>
Kein Wachstum oder verunreinigte Proben werden später automatisch vom
Antibiogramm ausgeschlossen.
Bei "Kein bakterielles Wachstum" oder "Verunreinigte Probe" wird das Antibiogramm
übersprungen und direkt zur Therapie weitergeleitet.
</p>
</div>
</article>

View File

@@ -167,9 +167,9 @@ export default function AntibiogramPage() {
<thead>
<tr>
<th>Antibiotikum</th>
<th>S</th>
<th>I</th>
<th>R</th>
<th className="matrix-col">Sensibel</th>
<th className="matrix-col">Intermediär</th>
<th className="matrix-col">Resistent</th>
</tr>
</thead>
<tbody>
@@ -180,7 +180,7 @@ export default function AntibiogramPage() {
<small className="table-subtext">{antibiotic.code ?? "ANT"}</small>
</td>
{(["SENSITIVE", "INTERMEDIATE", "RESISTANT"] as SensitivityResult[]).map((result) => (
<td key={result}>
<td key={result} className="matrix-col">
<button
type="button"
className={`matrix-button ${
@@ -191,7 +191,7 @@ export default function AntibiogramPage() {
onClick={() => updateResult(group.referenceQuarter, antibiotic.businessKey, result)}
disabled={!sample.antibiogramEditable}
>
{result === "SENSITIVE" ? "S" : result === "INTERMEDIATE" ? "I" : "R"}
{result === "SENSITIVE" ? "Sensibel" : result === "INTERMEDIATE" ? "Intermediär" : "Resistent"}
</button>
</td>
))}

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>
) : (
@@ -158,35 +389,252 @@ export default function InvoiceManagementPage() {
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-card__value">{invoices.length}</span>
<span className="stat-card__label">Gesamtrechnungen</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
<div className="table-shell">
<table className="data-table">
<tbody>
<tr>
<td style={{ fontWeight: 500 }}>Gesamtrechnungen</td>
<td style={{ textAlign: "right" }}>{invoices.length}</td>
</tr>
<tr>
<td style={{ fontWeight: 500 }}>Bezahlt</td>
<td style={{ textAlign: "right" }}>
{invoices.filter((i) => i.status === "PAID").length}
</span>
<span className="stat-card__label">Bezahlt</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
</td>
</tr>
<tr>
<td style={{ fontWeight: 500 }}>Überfällig</td>
<td style={{ textAlign: "right" }}>
{invoices.filter((i) => i.status === "OVERDUE").length}
</span>
<span className="stat-card__label">Überfällig</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
</td>
</tr>
<tr>
<td style={{ fontWeight: 500 }}>Gesamtumsatz</td>
<td style={{ textAlign: "right" }}>
{formatCurrency(
invoices
.filter((i) => i.status === "PAID")
.reduce((sum, i) => sum + i.totalAmount, 0)
)}
</span>
<span className="stat-card__label">Gesamtumsatz</span>
</div>
</td>
</tr>
</tbody>
</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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,11 @@ import { apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { SessionResponse } from "../lib/types";
interface RegistrationResponse {
userId: string;
email: string;
}
type FeedbackState =
| { type: "error"; text: string }
| { type: "success"; text: string }
@@ -86,14 +91,24 @@ export default function LoginPage() {
setFeedback(null);
const { passwordConfirmation, ...registrationPayload } = registration;
void passwordConfirmation;
const response = await apiPost<SessionResponse>("/session/register", registrationPayload);
const response = await apiPost<RegistrationResponse>("/session/register", registrationPayload);
setFeedback({
type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.user.companyName ?? response.user.displayName}.`,
text: `Registrierung erfolgreich. Ihr Account (${response.email}) wurde angelegt und muss durch einen Administrator freigegeben werden. Sie werden benachrichtigt, sobald die Freigabe erfolgt ist.`,
});
setSession(response);
// Admin zum Dashboard, Kunden zur Startseite
navigate(response.user.role === "ADMIN" ? "/admin/dashboard" : "/home");
// Reset registration form and switch back to login
setRegistration({
companyName: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
password: "",
passwordConfirmation: "",
});
setShowRegistration(false);
} catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message });
}
@@ -179,7 +194,7 @@ export default function LoginPage() {
</div>
</form>
) : (
<form className={`login-panel__section ${showRegisterValidation ? "show-validation" : ""}`} onSubmit={handleRegister}>
<form className={`login-panel__section ${showRegisterValidation ? "show-validation" : ""}`} onSubmit={handleRegister} autoComplete="off">
<div className="field-grid">
<label className="field field--wide field--required">
<span>Firmenname</span>
@@ -244,6 +259,7 @@ export default function LoginPage() {
onChange={(event) =>
setRegistration((current) => ({ ...current, email: event.target.value }))
}
autoComplete="off"
required
/>
</label>
@@ -256,6 +272,7 @@ export default function LoginPage() {
setRegistration((current) => ({ ...current, phoneNumber: event.target.value }))
}
placeholder="z. B. 04531 181424"
autoComplete="off"
required
/>
</label>
@@ -267,6 +284,7 @@ export default function LoginPage() {
onChange={(event) =>
setRegistration((current) => ({ ...current, password: event.target.value }))
}
autoComplete="new-password"
required
/>
</label>
@@ -290,6 +308,7 @@ export default function LoginPage() {
passwordConfirmation: event.target.value,
}))
}
autoComplete="new-password"
required
/>
</label>

View File

@@ -0,0 +1,160 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
interface PricingResponse {
monthlyPrice: number | null;
updatedAt: string | null;
}
export default function PricingPage() {
const [monthlyPrice, setMonthlyPrice] = useState<string>("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
useEffect(() => {
async function loadPricing() {
try {
const response = await apiGet<PricingResponse>("/admin/pricing");
if (response.monthlyPrice !== null) {
setMonthlyPrice(response.monthlyPrice.toString());
}
setLastUpdated(response.updatedAt);
} catch (error) {
setMessage((error as Error).message);
} finally {
setLoading(false);
}
}
void loadPricing();
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const priceValue = parseFloat(monthlyPrice.replace(",", "."));
if (isNaN(priceValue) || priceValue < 0) {
setMessage("Bitte geben Sie einen gültigen Preis ein.");
return;
}
setSaving(true);
setMessage(null);
try {
const response = await apiPost<PricingResponse>("/admin/pricing", {
monthlyPrice: priceValue,
});
setMonthlyPrice(response.monthlyPrice?.toString() ?? "");
setLastUpdated(response.updatedAt);
setMessage("Preis wurde erfolgreich gespeichert.");
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
} finally {
setSaving(false);
}
}
function formatDate(value: string | null): string {
if (!value) return "-";
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
if (loading) {
return (
<div className="page-stack">
<section className="section-card">
<div className="empty-state">Preistabelle wird geladen...</div>
</section>
</div>
);
}
return (
<div className="page-stack">
{/* Header */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Preistabelle</p>
<h3>Monatlichen Systempreis festlegen</h3>
<p className="muted-text">
Legen Sie hier den monatlichen Preis für die Nutzung des Systems fest.
Dieser Preis wird für die Rechnungsstellung verwendet.
</p>
</div>
</section>
{/* Status-Meldung */}
{message && (
<div
className={
message.includes("erfolgreich")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
)}
{/* Preis-Formular */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Systempreis</p>
<h3>Monatlicher Preis</h3>
</div>
</div>
<form onSubmit={handleSubmit} className="field-grid field-grid--2col">
<label className="field">
<span>Preis pro Monat () *</span>
<input
type="text"
value={monthlyPrice}
onChange={(e) => setMonthlyPrice(e.target.value)}
placeholder="z.B. 49,99"
required
disabled={saving}
/>
</label>
<div className="field" style={{ display: "flex", alignItems: "flex-end" }}>
<button
type="submit"
className="accent-button"
disabled={saving}
style={{ width: "100%" }}
>
{saving ? "Wird gespeichert..." : "Preis speichern"}
</button>
</div>
</form>
{lastUpdated && (
<div style={{ marginTop: "1rem", fontSize: "0.875rem", color: "var(--muted-text)" }}>
<strong>Zuletzt aktualisiert:</strong> {formatDate(lastUpdated)}
</div>
)}
</section>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Der hier festgelegte monatliche Preis wird als Grundlage für die Abrechnung
mit den Nutzern verwendet. Änderungen werden sofort wirksam und bei der
nächsten Rechnungsstellung berücksichtigt.
</p>
</div>
</section>
</div>
);
}

View File

@@ -1293,7 +1293,6 @@ export default function ReportTemplatePage() {
setResizingElementId(null);
setTemplateUpdatedAt(null);
setTemplateError(null);
setIsTemplateApiAvailable(false);
return;
}
setTemplateError((error as Error).message);

View File

@@ -18,6 +18,14 @@ const QUARTERS: { key: QuarterKey; label: string }[] = [
{ key: "RIGHT_REAR", label: "Hinten rechts" },
];
// Pretreatment options from Lua version
const PRETREATMENT_OPTIONS = [
{ key: "inUdderInjector", label: "Euterinjektor" },
{ key: "systemicAntibiotics", label: "Systemische Antibiotika" },
{ key: "painMedication", label: "Schmerzmittel" },
{ key: "dryOffTreatment", label: "Trockensteller" },
];
export default function SampleRegistrationPage() {
const { sampleId } = useParams();
const navigate = useNavigate();
@@ -32,6 +40,15 @@ export default function SampleRegistrationPage() {
const [sampleKind, setSampleKind] = useState<SampleKind>("LACTATION");
const [samplingMode, setSamplingMode] = useState<SamplingMode>("SINGLE_SITE");
const [flaggedQuarters, setFlaggedQuarters] = useState<QuarterKey[]>([]);
// New fields from Lua version
const [pretreatment, setPretreatment] = useState<Record<string, string>>({
inUdderInjector: "",
systemicAntibiotics: "",
painMedication: "",
dryOffTreatment: "",
});
const [clinicalExamDate, setClinicalExamDate] = useState("");
const [internalNote, setInternalNote] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
@@ -55,6 +72,17 @@ export default function SampleRegistrationPage() {
setFlaggedQuarters(
sample.quarters.filter((quarter) => quarter.flagged).map((quarter) => quarter.quarterKey),
);
// Load new fields
if (sample.pretreatment) {
setPretreatment({
inUdderInjector: sample.pretreatment.inUdderInjector ?? "",
systemicAntibiotics: sample.pretreatment.systemicAntibiotics ?? "",
painMedication: sample.pretreatment.painMedication ?? "",
dryOffTreatment: sample.pretreatment.dryOffTreatment ?? "",
});
}
setClinicalExamDate(sample.clinicalExamDate ?? "");
setInternalNote(sample.internalNote ?? "");
} else {
const dashboard = await apiGet<DashboardOverview>("/dashboard");
setSampleNumber(dashboard.nextSampleNumber);
@@ -78,6 +106,13 @@ export default function SampleRegistrationPage() {
);
}
function updatePretreatment(key: string, value: string) {
setPretreatment((current) => ({
...current,
[key]: value,
}));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
@@ -101,6 +136,13 @@ export default function SampleRegistrationPage() {
flaggedQuarters,
userCode: user.displayName,
userDisplayName: user.displayName,
// New fields
pretreatmentInUdderInjector: pretreatment.inUdderInjector || null,
pretreatmentSystemicAntibiotics: pretreatment.systemicAntibiotics || null,
pretreatmentPainMedication: pretreatment.painMedication || null,
pretreatmentDryOffTreatment: pretreatment.dryOffTreatment || null,
clinicalExamDate: clinicalExamDate || null,
internalNote: internalNote || null,
};
try {
@@ -126,7 +168,7 @@ export default function SampleRegistrationPage() {
<p className="eyebrow">Neuanlage</p>
<h3>Probe {sampleNumber ?? "..."}</h3>
<p className="muted-text">
Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich ueber den
Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich über den
Schalter Trockenstellerprobe markieren.
</p>
</div>
@@ -154,7 +196,7 @@ export default function SampleRegistrationPage() {
>
{catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name}
{farmer.companyName}
</option>
))}
</select>
@@ -258,6 +300,55 @@ export default function SampleRegistrationPage() {
</section>
) : null}
{/* New section: Pretreatment from Lua version */}
<section className="section-card">
<p className="eyebrow">Vorbehandelt mit</p>
<div className="field-grid field-grid--stacked">
{PRETREATMENT_OPTIONS.map((option) => (
<label key={option.key} className="field">
<span>{option.label}</span>
<input
type="text"
value={pretreatment[option.key]}
onChange={(event) => updatePretreatment(option.key, event.target.value)}
disabled={!editable}
placeholder="Ohne Vorbehandlung"
/>
</label>
))}
</div>
</section>
{/* New section: Clinical Exam Date from Lua version */}
<section className="form-grid">
<article className="section-card">
<p className="eyebrow">Klinische Untersuchung</p>
<label className="field">
<span>Untersuchungsdatum (TT.MM.JJJJ)</span>
<input
type="text"
value={clinicalExamDate}
onChange={(event) => setClinicalExamDate(event.target.value)}
disabled={!editable}
placeholder="z.B. 15.03.2024"
/>
</label>
</article>
<article className="section-card">
<p className="eyebrow">Interne Bemerkung</p>
<label className="field">
<span>Bemerkung zur Probe</span>
<textarea
value={internalNote}
onChange={(event) => setInternalNote(event.target.value)}
disabled={!editable}
rows={3}
/>
</label>
</article>
</section>
<div className="page-actions">
<button type="submit" className="accent-button" disabled={saving || !editable}>
{saving ? "Speichern ..." : "Speichern"}

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

@@ -11,6 +11,12 @@ function medicationOptions(catalogs: ActiveCatalogSummary, category: MedicationC
return catalogs.medications.filter((medication) => medication.category === category);
}
// Options for dropdowns like in Lua version
const COUNT_OPTIONS = ["1", "2", "3", "4", "5"];
const DURATION_OPTIONS = ["1 Tag", "2 Tage", "3 Tage", "4 Tage", "5 Tage", "6 Tage", "7 Tage", "8 Tage", "10 Tage", "14 Tage"];
const DOSAGE_OPTIONS = ["einmalig", "1 x", "2 x", "3 x"];
const LOCATION_OPTIONS = ["i.m.", "i.v.", "s.c."];
export default function TherapyPage() {
const { sampleId } = useParams();
@@ -26,6 +32,15 @@ export default function TherapyPage() {
const [dryAntibioticKeys, setDryAntibioticKeys] = useState<string[]>([]);
const [farmerNote, setFarmerNote] = useState("");
const [internalNote, setInternalNote] = useState("");
// New fields from Lua version
const [inUdderCount, setInUdderCount] = useState("");
const [inUdderDuration, setInUdderDuration] = useState("");
const [systemicCount, setSystemicCount] = useState("");
const [systemicDuration, setSystemicDuration] = useState("");
const [systemicDosage, setSystemicDosage] = useState("");
const [systemicLocation, setSystemicLocation] = useState("");
const [startvacVaccination, setStartvacVaccination] = useState(false);
const [noAntibioticTreatment, setNoAntibioticTreatment] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
@@ -51,6 +66,15 @@ export default function TherapyPage() {
setDryAntibioticKeys(sampleResponse.therapy?.dryAntibioticKeys ?? []);
setFarmerNote(sampleResponse.therapy?.farmerNote ?? "");
setInternalNote(sampleResponse.therapy?.internalNote ?? "");
// Load new fields
setInUdderCount(sampleResponse.therapy?.inUdderCount ?? "");
setInUdderDuration(sampleResponse.therapy?.inUdderDuration ?? "");
setSystemicCount(sampleResponse.therapy?.systemicCount ?? "");
setSystemicDuration(sampleResponse.therapy?.systemicDuration ?? "");
setSystemicDosage(sampleResponse.therapy?.systemicDosage ?? "");
setSystemicLocation(sampleResponse.therapy?.systemicLocation ?? "");
setStartvacVaccination(sampleResponse.therapy?.startvacVaccination ?? false);
setNoAntibioticTreatment(sampleResponse.therapy?.noAntibioticTreatment ?? false);
} catch (loadError) {
setMessage((loadError as Error).message);
}
@@ -65,6 +89,14 @@ export default function TherapyPage() {
setter(list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]);
}
// Check if "Keine" (None) is selected for in-udder or systemic
const noInUdderSelected = inUdderMedicationKeys.some(key =>
catalogs?.medications.find(m => m.businessKey === key)?.name === "Keine"
);
const noSystemicSelected = systemicMedicationKeys.some(key =>
catalogs?.medications.find(m => m.businessKey === key)?.name === "Keine"
);
async function handleSave() {
if (!sampleId) {
return;
@@ -83,6 +115,15 @@ export default function TherapyPage() {
dryAntibioticKeys,
farmerNote,
internalNote,
// New fields
inUdderCount: inUdderCount || null,
inUdderDuration: inUdderDuration || null,
systemicCount: systemicCount || null,
systemicDuration: systemicDuration || null,
systemicDosage: systemicDosage || null,
systemicLocation: systemicLocation || null,
startvacVaccination,
noAntibioticTreatment,
});
setSample(response);
setMessage(response.completed ? "Probe gespeichert und abgeschlossen." : "Aenderung gespeichert.");
@@ -168,7 +209,35 @@ export default function TherapyPage() {
))}
</div>
{/* In-Udder Details from Lua */}
{!noInUdderSelected && inUdderMedicationKeys.length > 0 && (
<div className="field-grid field-grid--2col section-card__spacer">
<label className="field">
<span>Anzahl</span>
<select
value={inUdderCount}
onChange={(e) => setInUdderCount(e.target.value)}
disabled={therapyLocked}
>
<option value="">-</option>
{COUNT_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
<label className="field">
<span>Dauer</span>
<select
value={inUdderDuration}
onChange={(e) => setInUdderDuration(e.target.value)}
disabled={therapyLocked}
>
<option value="">-</option>
{DURATION_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
</div>
)}
<label className="field section-card__spacer">
<span>Sonstiges</span>
<textarea
value={inUdderOther}
@@ -202,7 +271,57 @@ export default function TherapyPage() {
)}
</div>
{/* Systemic Details from Lua */}
{!noSystemicSelected && systemicMedicationKeys.length > 0 && (
<div className="field-grid field-grid--2col section-card__spacer">
<label className="field">
<span>Anzahl</span>
<select
value={systemicCount}
onChange={(e) => setSystemicCount(e.target.value)}
disabled={therapyLocked}
>
<option value="">-</option>
{COUNT_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
<label className="field">
<span>Dauer</span>
<select
value={systemicDuration}
onChange={(e) => setSystemicDuration(e.target.value)}
disabled={therapyLocked}
>
<option value="">-</option>
{DURATION_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
<label className="field">
<span>Dosierung</span>
<select
value={systemicDosage}
onChange={(e) => setSystemicDosage(e.target.value)}
disabled={therapyLocked}
>
<option value="">-</option>
{DOSAGE_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
<label className="field">
<span>Ort</span>
<select
value={systemicLocation}
onChange={(e) => setSystemicLocation(e.target.value)}
disabled={therapyLocked}
>
<option value="">-</option>
{LOCATION_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
</div>
)}
<label className="field section-card__spacer">
<span>Sonstiges</span>
<textarea
value={systemicOther}
@@ -254,6 +373,33 @@ export default function TherapyPage() {
</section>
)}
{/* Additional Options from Lua */}
{sample.sampleKind === "LACTATION" && (
<section className="section-card">
<p className="eyebrow">Sonstiges</p>
<div className="field-grid">
<label className="field field--checkbox">
<input
type="checkbox"
checked={noAntibioticTreatment}
onChange={(e) => setNoAntibioticTreatment(e.target.checked)}
disabled={therapyLocked}
/>
<span>Keine Antibiose, gut ausmelken (evtl. Oxytocin), als letzte melken, strikte Hygiene</span>
</label>
<label className="field field--checkbox">
<input
type="checkbox"
checked={startvacVaccination}
onChange={(e) => setStartvacVaccination(e.target.checked)}
disabled={therapyLocked}
/>
<span>Startvac-Impfung</span>
</label>
</div>
</section>
)}
<section className="form-grid">
<article className="section-card">
<label className="field">

View File

@@ -3,38 +3,113 @@ import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserRow } from "../lib/types";
interface PrimaryUserRow {
interface SubUserRow {
id: string;
displayName: string;
companyName: string | null;
email: string | null;
active: boolean;
updatedAt: string;
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<PrimaryUserRow[]>([]);
const { user, updateUser } = useSession();
const [users, setUsers] = useState<SubUserRow[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const isAdmin = user?.role === "ADMIN";
const isPrimaryUser = user?.primaryUser === true;
const canManageUsers = isAdmin || isPrimaryUser;
// Form state for creating new sub-user
const [newUserName, setNewUserName] = useState("");
const [newUserEmail, setNewUserEmail] = useState("");
const [newUserPassword, setNewUserPassword] = useState("");
const [newUserPasswordConfirm, setNewUserPasswordConfirm] = useState("");
const [creating, setCreating] = useState(false);
// Form state for editing own profile
const [showProfileForm, setShowProfileForm] = useState(false);
const [profileData, setProfileData] = useState({
displayName: "",
companyName: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
});
const [savingProfile, setSavingProfile] = useState(false);
// Initialize profile data from session user
useEffect(() => {
if (user) {
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 || "",
});
}
}, [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 {
const response = await apiGet<UserRow[]>("/portal/users");
// Nur Hauptnutzer (primaryUser=true) anzeigen, aber Admin ausblenden
if (isAdmin) {
// Admin sieht alle Hauptnutzer (außer andere Admins)
const primaryUsers = response
.filter((u) => u.primaryUser && u.role !== "ADMIN")
.map((u) => ({
id: u.id,
displayName: u.displayName,
companyName: u.companyName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(primaryUsers);
} else {
// Hauptnutzer sieht alle seine Unterbenutzer (nicht-primary)
const subUsers = response
.filter((u) => !u.primaryUser)
.map((u) => ({
id: u.id,
displayName: u.displayName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(subUsers);
}
} catch (error) {
setMessage((error as Error).message);
} finally {
@@ -43,7 +118,7 @@ export default function UserManagementPage() {
}
void loadUsers();
}, []);
}, [isAdmin]);
async function toggleUserStatus(userId: string, newStatus: boolean) {
try {
@@ -51,7 +126,7 @@ export default function UserManagementPage() {
if (!userToUpdate) return;
await apiPost("/portal/users", {
id: userId,
...userToUpdate,
active: newStatus,
});
@@ -65,14 +140,113 @@ export default function UserManagementPage() {
}.`
);
// Nachricht nach 3 Sekunden ausblenden
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
}
}
// Formatierungsfunktion für das Datum
async function handleCreateUser(e: React.FormEvent) {
e.preventDefault();
if (!newUserName.trim() || !newUserEmail.trim() || !newUserPassword.trim()) {
setMessage("Bitte alle Felder ausfüllen.");
return;
}
if (newUserPassword !== newUserPasswordConfirm) {
setMessage("Die Passwörter stimmen nicht überein.");
return;
}
setCreating(true);
try {
await apiPost("/portal/users", {
displayName: newUserName.trim(),
email: newUserEmail.trim(),
password: newUserPassword,
});
// Reload users
const response = await apiGet<UserRow[]>("/portal/users");
const subUsers = response
.filter((u) => !u.primaryUser)
.map((u) => ({
id: u.id,
displayName: u.displayName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(subUsers);
// Reset form
setNewUserName("");
setNewUserEmail("");
setNewUserPassword("");
setNewUserPasswordConfirm("");
setShowCreateForm(false);
setMessage(`Benutzer "${newUserName}" wurde erstellt.`);
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
} finally {
setCreating(false);
}
}
function closeCreateUserDialog() {
setShowCreateForm(false);
setNewUserName("");
setNewUserEmail("");
setNewUserPassword("");
setNewUserPasswordConfirm("");
}
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
if (!profileData.displayName.trim()) {
setMessage("Name ist erforderlich.");
return;
}
setSavingProfile(true);
try {
const response = await apiPost<UserRow>("/portal/users", {
id: user?.id,
displayName: profileData.displayName.trim(),
companyName: profileData.companyName.trim() || null,
street: profileData.street.trim() || null,
houseNumber: profileData.houseNumber.trim() || null,
postalCode: profileData.postalCode.trim() || null,
city: profileData.city.trim() || null,
email: profileData.email.trim() || null,
phoneNumber: profileData.phoneNumber.trim() || null,
});
// Update session user
if (updateUser && user) {
updateUser({
...user,
displayName: response.displayName,
companyName: response.companyName,
street: response.street,
houseNumber: response.houseNumber,
postalCode: response.postalCode,
city: response.city,
email: response.email,
phoneNumber: response.phoneNumber,
});
}
setShowProfileForm(false);
setMessage("Ihre Stammdaten wurden aktualisiert.");
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
} finally {
setSavingProfile(false);
}
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
@@ -80,13 +254,12 @@ export default function UserManagementPage() {
}).format(new Date(value));
}
// Nicht-Admin Ansicht (sollte nicht passieren, da Route geschützt ist)
if (!isAdmin) {
if (!canManageUsers) {
return (
<div className="page-stack">
<section className="section-card">
<div className="alert alert--error">
Zugriff verweigert. Diese Seite ist nur für Administratoren.
Zugriff verweigert. Diese Seite ist nur für Administratoren und Hauptbenutzer.
</div>
</section>
</div>
@@ -99,10 +272,11 @@ export default function UserManagementPage() {
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Benutzerverwaltung</p>
<h3>Hauptnutzer freigeben oder sperren</h3>
<h3>{isAdmin ? "Hauptnutzer freigeben oder sperren" : "Unterbenutzer verwalten"}</h3>
<p className="muted-text">
Verwalten Sie den Zugriff von Hauptnutzern auf das System.
Gesperrte Benutzer können sich nicht mehr anmelden.
{isAdmin
? "Verwalten Sie den Zugriff von Hauptnutzern auf das System. Gesperrte Benutzer können sich nicht mehr anmelden."
: "Erstellen und verwalten Sie Unterbenutzer für Ihr Konto. Unterbenutzer können Proben registrieren und bearbeiten."}
</p>
</div>
</section>
@@ -111,7 +285,10 @@ export default function UserManagementPage() {
{message ? (
<div
className={
message.includes("freigegeben") || message.includes("gesperrt")
message.includes("freigegeben") ||
message.includes("gesperrt") ||
message.includes("erstellt") ||
message.includes("aktualisiert")
? "alert alert--success"
: "alert alert--error"
}
@@ -120,26 +297,47 @@ export default function UserManagementPage() {
</div>
) : null}
{/* Tabelle mit Hauptnutzern */}
{/* Own Profile Form (nur für Hauptbenutzer) */}
{isPrimaryUser && !isAdmin && (
<section className="section-card">
<div className="section-card__header user-management-page__profile-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">Hauptnutzer</p>
<h3>Registrierte Hauptnutzer</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">Keine Hauptnutzer vorhanden.</div>
<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>
<th>Firma</th>
{isAdmin && <th>Firma</th>}
<th>E-Mail</th>
<th>Status</th>
<th>Letzte Änderung</th>
@@ -152,7 +350,7 @@ export default function UserManagementPage() {
<td>
<strong>{entry.displayName}</strong>
</td>
<td>{entry.companyName ?? "-"}</td>
{isAdmin && <td>{(entry as SubUserRow & { companyName?: string }).companyName ?? "-"}</td>}
<td>{entry.email ?? "-"}</td>
<td>
<span
@@ -181,19 +379,208 @@ export default function UserManagementPage() {
</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>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Hauptnutzer sind die primären Kontoinhaber. Wenn Sie einen Hauptnutzer sperren,
können sich dieser und alle zugehörigen Nebennutzer nicht mehr anmelden.
Die Daten bleiben erhalten und können durch Freigabe wieder aktiviert werden.
</p>
{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>
</section>
</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>
</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
type="text"
value={profileData.displayName}
onChange={(e) => setProfileData({ ...profileData, displayName: e.target.value })}
placeholder="Ihr Name"
required
/>
</label>
<label className="field">
<span>Firma</span>
<input
type="text"
value={profileData.companyName}
onChange={(e) => setProfileData({ ...profileData, companyName: e.target.value })}
placeholder="Firmenname"
/>
</label>
<label className="field">
<span>Straße</span>
<input
type="text"
value={profileData.street}
onChange={(e) => setProfileData({ ...profileData, street: e.target.value })}
placeholder="Straße"
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
type="text"
value={profileData.houseNumber}
onChange={(e) => setProfileData({ ...profileData, houseNumber: e.target.value })}
placeholder="Hausnummer"
/>
</label>
<label className="field">
<span>PLZ</span>
<input
type="text"
value={profileData.postalCode}
onChange={(e) => setProfileData({ ...profileData, postalCode: e.target.value })}
placeholder="Postleitzahl"
/>
</label>
<label className="field">
<span>Ort</span>
<input
type="text"
value={profileData.city}
onChange={(e) => setProfileData({ ...profileData, city: e.target.value })}
placeholder="Ort"
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={profileData.email}
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
placeholder="email@beispiel.de"
/>
</label>
<label className="field">
<span>Telefon</span>
<input
type="tel"
value={profileData.phoneNumber}
onChange={(e) => setProfileData({ ...profileData, phoneNumber: e.target.value })}
placeholder="Telefonnummer"
/>
</label>
</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"
disabled={savingProfile}
>
{savingProfile ? "Wird gespeichert..." : "Stammdaten speichern"}
</button>
</div>
</form>
</div>
) : null}
</div>
);
}

View File

@@ -102,6 +102,13 @@ a {
letter-spacing: 0.08em;
}
.sidebar__version {
font-size: 0.65em;
font-weight: 400;
opacity: 0.7;
margin-left: 6px;
}
.sidebar__nav {
display: grid;
gap: 10px;
@@ -563,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;
@@ -583,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;
@@ -624,10 +748,17 @@ a {
}
.matrix-button {
width: 42px;
min-width: 90px;
width: auto;
height: 42px;
padding: 0 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.matrix-col {
text-align: center !important;
}
.eye-button {
@@ -767,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;
@@ -860,7 +999,7 @@ a {
.invoice-template__palette-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
}
.invoice-template__tile {
@@ -964,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;
@@ -1007,10 +1146,71 @@ a {
word-break: break-word;
}
.invoice-template__canvas-line {
display: block;
.invoice-template__muh-items {
display: grid;
width: 100%;
height: 100%;
min-height: 0;
grid-template-rows: auto 2px auto minmax(0, 1fr) auto 2px auto;
gap: 10px;
line-height: 1.35;
}
.invoice-template__muh-items-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 18px;
}
.invoice-template__muh-items-row--amount-side {
grid-template-columns: minmax(0, 1fr) fit-content(240px) auto;
}
.invoice-template__muh-items-row--amount-side .invoice-template__muh-items-label {
grid-column: 2;
justify-self: start;
}
.invoice-template__muh-items-row--amount-side .invoice-template__muh-items-amount {
grid-column: 3;
}
.invoice-template__muh-items-row--total {
align-self: end;
}
.invoice-template__muh-items-label {
display: block;
min-width: 0;
white-space: normal;
word-break: break-word;
}
.invoice-template__muh-items-amount {
display: block;
justify-self: end;
white-space: nowrap;
text-align: right;
}
.invoice-template__muh-items-separator {
display: block;
width: 100%;
height: 2px;
border-radius: 999px;
background: rgba(37, 49, 58, 0.82);
pointer-events: none;
}
.invoice-template__muh-items-spacer {
display: block;
min-height: 0;
}
.invoice-template__canvas-line {
position: absolute;
display: block;
border-radius: 999px;
background: rgba(37, 49, 58, 0.82);
pointer-events: none;
@@ -1376,6 +1576,10 @@ a {
justify-content: flex-end;
}
.dialog__actions--start {
justify-content: flex-start;
}
.dialog__actions a {
display: inline-flex;
align-items: center;
@@ -1387,6 +1591,11 @@ a {
min-height: 0;
}
.dialog__body--form,
.dialog__body--farmer {
overflow: auto;
}
.dialog__body--pdf {
height: min(80vh, 900px);
}
@@ -1489,3 +1698,84 @@ a {
height: 72vh;
}
}
/* Additional styles for new Lua features */
/* Checkbox field styling */
.field--checkbox {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
}
.field--checkbox input[type="checkbox"] {
width: 20px;
height: 20px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--accent);
}
.field--checkbox span {
font-size: 0.9rem;
color: var(--text);
line-height: 1.5;
}
/* Special pathogen buttons (NO_GROWTH, CONTAMINATED) */
.pathogen-button.special-pathogen {
background: rgba(138, 101, 0, 0.1);
border: 1px solid rgba(138, 101, 0, 0.2);
}
.pathogen-button.special-pathogen.is-selected {
background: linear-gradient(135deg, rgba(138, 101, 0, 0.25), rgba(138, 101, 0, 0.1));
box-shadow: inset 0 0 0 1px rgba(138, 101, 0, 0.4);
}
/* Therapy detail fields */
.field-grid--2col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 640px) {
.field-grid--2col {
grid-template-columns: 1fr;
}
}
/* Disabled state for therapy when "Keine" is selected */
.choice-chip:disabled,
.field input:disabled,
.field select:disabled,
.field textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Pretreatment section styling */
.pretreatment-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 640px) {
.pretreatment-grid {
grid-template-columns: 1fr;
}
}
/* Version Footer */
.version-footer {
text-align: center;
padding: 24px 0;
color: var(--muted);
font-size: 0.85rem;
opacity: 0.7;
}
.version-footer span {
font-weight: 500;
}

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",