Compare commits

...

46 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
eb0f921464 feat: Add invoice management menu and template editor for admin
- Add 'Rechnung' menu with sub-items 'Verwalten' and 'Template' in admin sidebar
- Create InvoiceTemplatePage with drag-and-drop editor for invoice templates
  - Includes invoice-specific elements (header, customer data, issuer info,
    invoice items, totals, payment terms, bank details)
  - Supports PDF preview and download
  - API integration for saving/loading templates (/admin/invoice-template)
- Create InvoiceManagementPage as placeholder for invoice overview
- Add routes for /admin/rechnung/verwalten and /admin/rechnung/template
- Update page titles in AppShell for new routes
2026-03-16 20:30:45 +01:00
cbabe13162 Admin Dashboard: Verwaltungsmodul-Bereich entfernt 2026-03-16 20:19:26 +01:00
538ec2419d AdminStatisticsService korrigiert: Proben werden jetzt basierend auf ownerAccountId und createdByUserCode korrekt zugeordnet 2026-03-16 20:17:27 +01:00
19fda276b0 Admin Dashboard mit Chart.js: Balkendiagramm zeigt Proben pro Tierarzt 2026-03-16 17:14:17 +01:00
1df2d8276c Admin Dashboard mit Statistiken: Tierärzte-Anzahl, Gesamtproben und Proben pro Tierarzt 2026-03-16 17:11:17 +01:00
2fd101565e Admin wird in der Benutzerverwaltung nicht mehr angezeigt 2026-03-16 17:04:27 +01:00
021730b90b UserManagementPage für Admin vereinfacht: Nur Hauptnutzer anzeigen mit Freigabe/Sperre-Funktion 2026-03-16 17:02:19 +01:00
2f9b12250f Admin Dashboard weiter reduziert: Statistik-Karten entfernt, nur noch Header und Benutzerverwaltung 2026-03-16 16:58:56 +01:00
89d6651af2 Admin Dashboard reduziert: Nur noch Statistiken und Benutzerverwaltung-Kachel 2026-03-16 16:56:55 +01:00
118e6431da Admin-Menü weiter reduziert: Nur noch Dashboard und Benutzerverwaltung (Freigabe/Sperre) 2026-03-16 16:54:57 +01:00
477fcb69c4 Admin-Menü reduziert: Nur noch Dashboard, Neue Probe, Benutzerverwaltung (Freigabe/Sperre) und Portal 2026-03-16 16:53:50 +01:00
40de46588e Admin Dashboard hinzugefügt: Modernes Dashboard für Administratoren mit Statistiken, Verwaltungsmodulen und Schnellzugriffen 2026-03-16 16:51:15 +01:00
63 changed files with 7826 additions and 787 deletions

View File

@@ -4,6 +4,7 @@ WORKDIR /build/frontend
ARG VITE_API_URL=/api ARG VITE_API_URL=/api
ENV VITE_API_URL=${VITE_API_URL} ENV VITE_API_URL=${VITE_API_URL}
COPY backend/pom.xml /build/backend/pom.xml
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN npm ci
@@ -19,7 +20,8 @@ RUN mvn -B -q -DskipTests dependency:go-offline
COPY backend/ ./ COPY backend/ ./
COPY --from=frontend-build /build/frontend/dist ./src/main/resources/static 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 FROM eclipse-temurin:21-jre-alpine AS runtime
@@ -27,7 +29,7 @@ WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring 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 USER spring:spring

View File

@@ -105,3 +105,9 @@ Kundenregistrierung:
- `cd backend && mvn test` - `cd backend && mvn test`
- `cd frontend && npm run build` - `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> <groupId>de.svencarstensen</groupId>
<artifactId>muh-backend</artifactId> <artifactId>muh-backend</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.9.4</version>
<name>muh-backend</name> <name>muh-backend</name>
<description>MUH application backend</description> <description>MUH application backend</description>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.domain; package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -19,10 +20,21 @@ public record AppUser(
String city, String city,
String email, String email,
String phoneNumber, String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String passwordHash, String passwordHash,
boolean active, boolean active,
UserRole role, UserRole role,
Long nextSampleNumber,
@Indexed(unique = true) String customerNumber,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
public AppUser {
if (nextSampleNumber == null) {
nextSampleNumber = 100000L;
}
}
} }

View File

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

View File

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

View File

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

View File

@@ -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.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -29,6 +30,10 @@ public record Sample(
LocalDateTime completedAt, LocalDateTime completedAt,
String ownerAccountId, String ownerAccountId,
String createdByUserCode, 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> dryAntibioticKeys,
List<String> dryAntibioticNames, List<String> dryAntibioticNames,
String farmerNote, 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> { public interface AntibioticCatalogRepository extends MongoRepository<AntibioticCatalogItem, String> {
List<AntibioticCatalogItem> findByActiveTrueOrderByNameAsc(); List<AntibioticCatalogItem> findByActiveTrueOrderByNameAsc();
List<AntibioticCatalogItem> findByAccountIdOrderByNameAsc(String accountId);
List<AntibioticCatalogItem> findByAccountIdAndActiveTrueOrderByNameAsc(String accountId);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ public interface SampleRepository extends MongoRepository<Sample, String> {
Optional<Sample> findTopByOrderBySampleNumberDesc(); Optional<Sample> findTopByOrderBySampleNumberDesc();
Optional<Sample> findTopByOwnerAccountIdOrderBySampleNumberDesc(String ownerAccountId);
List<Sample> findTop12ByOrderByUpdatedAtDesc(); List<Sample> findTop12ByOrderByUpdatedAtDesc();
List<Sample> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey); 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.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@@ -11,6 +12,7 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration @Configuration
@EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
@Bean @Bean
@@ -23,6 +25,8 @@ public class SecurityConfig {
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll() .requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll()
.requestMatchers("/api/catalog/**").hasRole("CUSTOMER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated() .requestMatchers("/api/**").authenticated()
.anyRequest().permitAll() .anyRequest().permitAll()
) )

View File

@@ -0,0 +1,70 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.Sample;
import de.svencarstensen.muh.domain.UserRole;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.SampleRepository;
import de.svencarstensen.muh.web.dto.AdminStatistics;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AdminStatisticsService {
private final AppUserRepository appUserRepository;
private final SampleRepository sampleRepository;
public AdminStatisticsService(AppUserRepository appUserRepository, SampleRepository sampleRepository) {
this.appUserRepository = appUserRepository;
this.sampleRepository = sampleRepository;
}
public AdminStatistics getStatistics() {
// Alle Hauptnutzer (Tierärzte) laden - primaryUser=true und role=CUSTOMER
List<AppUser> vets = appUserRepository.findAll().stream()
.filter(u -> Boolean.TRUE.equals(u.primaryUser()))
.filter(u -> u.role() == UserRole.CUSTOMER)
.toList();
// Alle Proben laden
List<Sample> allSamples = sampleRepository.findAll();
// Proben pro Tierarzt zählen (basierend auf ownerAccountId oder createdByUserCode)
List<AdminStatistics.VetSampleStats> samplesPerVet = vets.stream()
.map(vet -> {
String vetId = vet.id();
String accountId = vet.accountId();
long sampleCount = allSamples.stream()
.filter(s -> {
// Prüfe sowohl ownerAccountId als auch createdByUserCode
String ownerId = s.ownerAccountId();
String creatorId = s.createdByUserCode();
// Vergleiche mit vet.id() oder vet.accountId()
return vetId.equals(ownerId) ||
vetId.equals(creatorId) ||
accountId != null && accountId.equals(ownerId) ||
accountId != null && accountId.equals(creatorId);
})
.count();
return new AdminStatistics.VetSampleStats(
vet.id(),
vet.displayName(),
vet.companyName(),
sampleCount
);
})
.filter(s -> s.sampleCount() > 0)
.sorted((a, b) -> Long.compare(b.sampleCount(), a.sampleCount()))
.toList();
return new AdminStatistics(
vets.size(),
allSamples.size(),
samplesPerVet
);
}
}

View File

@@ -14,7 +14,6 @@ import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository; import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository; import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import de.svencarstensen.muh.security.AuthTokenService; import de.svencarstensen.muh.security.AuthTokenService;
import de.svencarstensen.muh.security.AuthorizationService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
@@ -42,7 +41,7 @@ public class CatalogService {
private static final Comparator<FarmerRow> FARMER_ROW_COMPARATOR = Comparator private static final Comparator<FarmerRow> FARMER_ROW_COMPARATOR = Comparator
.comparing(FarmerRow::active).reversed() .comparing(FarmerRow::active).reversed()
.thenComparing(FarmerRow::name, String.CASE_INSENSITIVE_ORDER) .thenComparing(FarmerRow::companyName, String.CASE_INSENSITIVE_ORDER)
.thenComparing(FarmerRow::updatedAt, Comparator.nullsLast(Comparator.reverseOrder())); .thenComparing(FarmerRow::updatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
private static final Comparator<MedicationRow> MEDICATION_ROW_COMPARATOR = Comparator private static final Comparator<MedicationRow> MEDICATION_ROW_COMPARATOR = Comparator
@@ -67,7 +66,6 @@ public class CatalogService {
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate; private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService; private final AuthTokenService authTokenService;
private final AuthorizationService authorizationService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService( public CatalogService(
@@ -77,8 +75,7 @@ public class CatalogService {
AntibioticCatalogRepository antibioticRepository, AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository, AppUserRepository appUserRepository,
MongoTemplate mongoTemplate, MongoTemplate mongoTemplate,
AuthTokenService authTokenService, AuthTokenService authTokenService
AuthorizationService authorizationService
) { ) {
this.farmerRepository = farmerRepository; this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository; this.medicationRepository = medicationRepository;
@@ -87,65 +84,96 @@ public class CatalogService {
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate; this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService; this.authTokenService = authTokenService;
this.authorizationService = authorizationService;
} }
public ActiveCatalogSummary activeCatalogSummary() { public ActiveCatalogSummary activeCatalogSummary(String actorId) {
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return new ActiveCatalogSummary( return new ActiveCatalogSummary(
farmerRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toFarmerOption).toList(), listActiveFarmersForActor(actor).stream().map(this::toFarmerOption).toList(),
medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(), listActiveMedicationsForActor(actor).stream().map(this::toMedicationOption).toList(),
pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(), listActivePathogensForActor(actor).stream().map(this::toPathogenOption).toList(),
antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).toList(), listActiveAntibioticsForActor(actor).stream().map(this::toAntibioticOption).toList(),
List.of() List.of()
); );
} }
public AdministrationOverview administrationOverview(String actorId) { public AdministrationOverview administrationOverview(String actorId) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows()); return new AdministrationOverview(
listFarmerRowsForActor(actor),
listMedicationRowsForActor(actor),
listPathogenRowsForActor(actor),
listAntibioticRowsForActor(actor)
);
} }
public List<FarmerRow> listFarmerRows() { // Hilfsmethoden für Datenzugriff (immer nur eigene Daten des Hauptbenutzers)
return farmerRepository.findAll().stream() private List<Farmer> listActiveFarmersForActor(AppUser actor) {
return farmerRepository.findByAccountIdAndActiveTrueOrderByCompanyNameAsc(resolveAccountId(actor));
}
private List<MedicationCatalogItem> listActiveMedicationsForActor(AppUser actor) {
return medicationRepository.findByAccountIdAndActiveTrueOrderByNameAsc(resolveAccountId(actor));
}
private List<PathogenCatalogItem> listActivePathogensForActor(AppUser actor) {
return pathogenRepository.findByAccountIdAndActiveTrueOrderByNameAsc(resolveAccountId(actor));
}
private List<AntibioticCatalogItem> listActiveAntibioticsForActor(AppUser actor) {
return antibioticRepository.findByAccountIdAndActiveTrueOrderByNameAsc(resolveAccountId(actor));
}
private List<FarmerRow> listFarmerRowsForActor(AppUser actor) {
return farmerRepository.findByAccountIdOrderByCompanyNameAsc(resolveAccountId(actor)).stream()
.map(this::toFarmerRow) .map(this::toFarmerRow)
.sorted(FARMER_ROW_COMPARATOR) .sorted(FARMER_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<MedicationRow> listMedicationRows() { private List<MedicationRow> listMedicationRowsForActor(AppUser actor) {
return medicationRepository.findAll().stream() return medicationRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toMedicationRow) .map(this::toMedicationRow)
.sorted(MEDICATION_ROW_COMPARATOR) .sorted(MEDICATION_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<PathogenRow> listPathogenRows() { private List<PathogenRow> listPathogenRowsForActor(AppUser actor) {
return pathogenRepository.findAll().stream() return pathogenRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toPathogenRow) .map(this::toPathogenRow)
.sorted(PATHOGEN_ROW_COMPARATOR) .sorted(PATHOGEN_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<AntibioticRow> listAntibioticRows() { private List<AntibioticRow> listAntibioticRowsForActor(AppUser actor) {
return antibioticRepository.findAll().stream() return antibioticRepository.findByAccountIdOrderByNameAsc(resolveAccountId(actor)).stream()
.map(this::toAntibioticRow) .map(this::toAntibioticRow)
.sorted(ANTIBIOTIC_ROW_COMPARATOR) .sorted(ANTIBIOTIC_ROW_COMPARATOR)
.toList(); .toList();
} }
public List<FarmerRow> saveFarmers(String actorId, List<FarmerMutation> mutations) { public List<FarmerRow> saveFarmers(String actorId, List<FarmerMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (FarmerMutation mutation : mutations) { for (FarmerMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.companyName())) {
continue; continue;
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
mutation.name().trim(), blankToNull(mutation.customerNumber()),
mutation.companyName().trim(),
blankToNull(mutation.contactPerson()),
blankToNull(mutation.street()),
blankToNull(mutation.houseNumber()),
blankToNull(mutation.postalCode()),
blankToNull(mutation.city()),
blankToNull(mutation.email()), blankToNull(mutation.email()),
blankToNull(mutation.phoneNumber()),
mutation.active(), mutation.active(),
null, null,
now, now,
@@ -156,14 +184,33 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Landwirt-ID fehlt"); String mutationId = requireText(mutation.id(), "Landwirt-ID fehlt");
Farmer existing = farmerRepository.findById(mutationId) Farmer existing = farmerRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Landwirt nicht gefunden"));
boolean changed = !existing.name().equals(mutation.name().trim()) // Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
|| !safeEquals(existing.email(), blankToNull(mutation.email())); if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.companyName().equals(mutation.companyName().trim())
|| !safeEquals(existing.customerNumber(), blankToNull(mutation.customerNumber()))
|| !safeEquals(existing.contactPerson(), blankToNull(mutation.contactPerson()))
|| !safeEquals(existing.street(), blankToNull(mutation.street()))
|| !safeEquals(existing.houseNumber(), blankToNull(mutation.houseNumber()))
|| !safeEquals(existing.postalCode(), blankToNull(mutation.postalCode()))
|| !safeEquals(existing.city(), blankToNull(mutation.city()))
|| !safeEquals(existing.email(), blankToNull(mutation.email()))
|| !safeEquals(existing.phoneNumber(), blankToNull(mutation.phoneNumber()));
if (changed) { if (changed) {
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.customerNumber(),
existing.companyName(),
existing.contactPerson(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(), existing.email(),
existing.phoneNumber(),
false, false,
existing.supersedesId(), existing.supersedesId(),
existing.createdAt(), existing.createdAt(),
@@ -171,9 +218,17 @@ public class CatalogService {
)); ));
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
mutation.name().trim(), blankToNull(mutation.customerNumber()),
mutation.companyName().trim(),
blankToNull(mutation.contactPerson()),
blankToNull(mutation.street()),
blankToNull(mutation.houseNumber()),
blankToNull(mutation.postalCode()),
blankToNull(mutation.city()),
blankToNull(mutation.email()), blankToNull(mutation.email()),
blankToNull(mutation.phoneNumber()),
mutation.active(), mutation.active(),
existing.id(), existing.id(),
now, now,
@@ -184,9 +239,17 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
farmerRepository.save(new Farmer( farmerRepository.save(new Farmer(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.customerNumber(),
existing.companyName(),
existing.contactPerson(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(), existing.email(),
existing.phoneNumber(),
mutation.active(), mutation.active(),
existing.supersedesId(), existing.supersedesId(),
existing.createdAt(), existing.createdAt(),
@@ -194,11 +257,12 @@ public class CatalogService {
)); ));
} }
} }
return listFarmerRows(); return listFarmerRowsForActor(actor);
} }
public List<MedicationRow> saveMedications(String actorId, List<MedicationMutation> mutations) { public List<MedicationRow> saveMedications(String actorId, List<MedicationMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (MedicationMutation mutation : mutations) { for (MedicationMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.category() == null) { if (isBlank(mutation.name()) || mutation.category() == null) {
continue; continue;
@@ -207,6 +271,7 @@ public class CatalogService {
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
mutation.name().trim(), mutation.name().trim(),
mutation.category(), mutation.category(),
@@ -220,11 +285,16 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Medikament-ID fehlt"); String mutationId = requireText(mutation.id(), "Medikament-ID fehlt");
MedicationCatalogItem existing = medicationRepository.findById(mutationId) MedicationCatalogItem existing = medicationRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Medikament nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Medikament nicht gefunden"));
// Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.name().equals(mutation.name().trim()) boolean changed = !existing.name().equals(mutation.name().trim())
|| existing.category() != mutation.category(); || existing.category() != mutation.category();
if (changed) { if (changed) {
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.name(),
existing.category(), existing.category(),
@@ -235,6 +305,7 @@ public class CatalogService {
)); ));
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
mutation.name().trim(), mutation.name().trim(),
mutation.category(), mutation.category(),
@@ -248,6 +319,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
medicationRepository.save(new MedicationCatalogItem( medicationRepository.save(new MedicationCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.name(), existing.name(),
existing.category(), existing.category(),
@@ -258,11 +330,12 @@ public class CatalogService {
)); ));
} }
} }
return listMedicationRows(); return listMedicationRowsForActor(actor);
} }
public List<PathogenRow> savePathogens(String actorId, List<PathogenMutation> mutations) { public List<PathogenRow> savePathogens(String actorId, List<PathogenMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (PathogenMutation mutation : mutations) { for (PathogenMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.kind() == null) { if (isBlank(mutation.name()) || mutation.kind() == null) {
continue; continue;
@@ -271,6 +344,7 @@ public class CatalogService {
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -285,12 +359,17 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Erreger-ID fehlt"); String mutationId = requireText(mutation.id(), "Erreger-ID fehlt");
PathogenCatalogItem existing = pathogenRepository.findById(mutationId) PathogenCatalogItem existing = pathogenRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Erreger nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Erreger nicht gefunden"));
// Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.name().equals(mutation.name().trim()) boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.code(), blankToNull(mutation.code())) || !safeEquals(existing.code(), blankToNull(mutation.code()))
|| existing.kind() != mutation.kind(); || existing.kind() != mutation.kind();
if (changed) { if (changed) {
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -302,6 +381,7 @@ public class CatalogService {
)); ));
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -316,6 +396,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
pathogenRepository.save(new PathogenCatalogItem( pathogenRepository.save(new PathogenCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -327,11 +408,12 @@ public class CatalogService {
)); ));
} }
} }
return listPathogenRows(); return listPathogenRowsForActor(actor);
} }
public List<AntibioticRow> saveAntibiotics(String actorId, List<AntibioticMutation> mutations) { public List<AntibioticRow> saveAntibiotics(String actorId, List<AntibioticMutation> mutations) {
authorizationService.requireActiveUser(actorId, "Nicht berechtigt"); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
String accountId = resolveAccountId(actor);
for (AntibioticMutation mutation : mutations) { for (AntibioticMutation mutation : mutations) {
if (isBlank(mutation.name())) { if (isBlank(mutation.name())) {
continue; continue;
@@ -340,6 +422,7 @@ public class CatalogService {
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
null, null,
accountId,
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -353,11 +436,16 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Antibiotika-ID fehlt"); String mutationId = requireText(mutation.id(), "Antibiotika-ID fehlt");
AntibioticCatalogItem existing = antibioticRepository.findById(mutationId) AntibioticCatalogItem existing = antibioticRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Antibiotikum nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Antibiotikum nicht gefunden"));
// Sicherstellen, dass der Benutzer nur seine eigenen Daten bearbeiten kann
if (!accountId.equals(existing.accountId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
boolean changed = !existing.name().equals(mutation.name().trim()) boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.code(), blankToNull(mutation.code())); || !safeEquals(existing.code(), blankToNull(mutation.code()));
if (changed) { if (changed) {
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -368,6 +456,7 @@ public class CatalogService {
)); ));
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
null, null,
existing.accountId(),
existing.businessKey(), existing.businessKey(),
blankToNull(mutation.code()), blankToNull(mutation.code()),
mutation.name().trim(), mutation.name().trim(),
@@ -381,6 +470,7 @@ public class CatalogService {
if (existing.active() != mutation.active()) { if (existing.active() != mutation.active()) {
antibioticRepository.save(new AntibioticCatalogItem( antibioticRepository.save(new AntibioticCatalogItem(
existing.id(), existing.id(),
existing.accountId(),
existing.businessKey(), existing.businessKey(),
existing.code(), existing.code(),
existing.name(), existing.name(),
@@ -391,7 +481,7 @@ public class CatalogService {
)); ));
} }
} }
return listAntibioticRows(); return listAntibioticRowsForActor(actor);
} }
public List<UserRow> listUsers(String actorId) { public List<UserRow> listUsers(String actorId) {
@@ -421,6 +511,7 @@ public class CatalogService {
} }
String userId = UUID.randomUUID().toString(); String userId = UUID.randomUUID().toString();
boolean adminManaged = actor.role() == UserRole.ADMIN; boolean adminManaged = actor.role() == UserRole.ADMIN;
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
userId, userId,
adminManaged ? userId : resolveAccountId(actor), adminManaged ? userId : resolveAccountId(actor),
@@ -436,9 +527,15 @@ public class CatalogService {
adminManaged ? blankToNull(mutation.city()) : null, adminManaged ? blankToNull(mutation.city()) : null,
normalizeEmail(mutation.email()), normalizeEmail(mutation.email()),
adminManaged ? blankToNull(mutation.phoneNumber()) : null, 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()), encodeIfPresent(mutation.password()),
mutation.active(), mutation.active(),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER, adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
100000L,
customerNumber,
now, now,
now now
)); ));
@@ -466,11 +563,17 @@ public class CatalogService {
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.city()) : existing.city(), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.city()) : existing.city(),
normalizeEmail(mutation.email()), normalizeEmail(mutation.email()),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.phoneNumber()) : existing.phoneNumber(), 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()), isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(), mutation.active(),
actor.role() == UserRole.ADMIN actor.role() == UserRole.ADMIN
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role())) ? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()), : normalizeStoredRole(existing.role()),
existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(), existing.createdAt(),
now now
)); ));
@@ -514,9 +617,15 @@ public class CatalogService {
existing.city(), existing.city(),
existing.email(), existing.email(),
existing.phoneNumber(), existing.phoneNumber(),
existing.accountHolder(),
existing.bankName(),
existing.iban(),
existing.bic(),
passwordEncoder.encode(newPassword), passwordEncoder.encode(newPassword),
existing.active(), existing.active(),
existing.role(), existing.role(),
existing.nextSampleNumber(),
existing.customerNumber(),
existing.createdAt(), existing.createdAt(),
LocalDateTime.now() LocalDateTime.now()
)); ));
@@ -534,7 +643,7 @@ public class CatalogService {
return toSessionResponse(user); return toSessionResponse(user);
} }
public SessionResponse registerCustomer(RegistrationMutation mutation) { public RegistrationResponse registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName()) if (isBlank(mutation.companyName())
|| isBlank(mutation.street()) || isBlank(mutation.street())
|| isBlank(mutation.houseNumber()) || isBlank(mutation.houseNumber())
@@ -563,6 +672,7 @@ public class CatalogService {
String address = formatAddress(street, houseNumber, postalCode, city); String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName; String displayName = companyName;
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String customerNumber = generateNextCustomerNumber();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
@@ -577,9 +687,15 @@ public class CatalogService {
city, city,
normalizedEmail, normalizedEmail,
phoneNumber, phoneNumber,
null,
null,
null,
null,
passwordEncoder.encode(mutation.password()), passwordEncoder.encode(mutation.password()),
true, false,
UserRole.CUSTOMER, UserRole.CUSTOMER,
100000L,
customerNumber,
now, now,
now now
)); ));
@@ -596,13 +712,22 @@ public class CatalogService {
created.city(), created.city(),
created.email(), created.email(),
created.phoneNumber(), created.phoneNumber(),
created.accountHolder(),
created.bankName(),
created.iban(),
created.bic(),
created.passwordHash(), created.passwordHash(),
created.active(), false,
created.role(), created.role(),
created.nextSampleNumber(),
created.customerNumber(),
created.createdAt(), created.createdAt(),
created.updatedAt() created.updatedAt()
)); ));
return toSessionResponse(accountBound); return new RegistrationResponse(accountBound.id(), accountBound.email());
}
public record RegistrationResponse(String userId, String email) {
} }
public UserOption currentUser(String actorId) { public UserOption currentUser(String actorId) {
@@ -617,28 +742,34 @@ public class CatalogService {
removeLegacyUserCodeField(); removeLegacyUserCodeField();
backfillDefaultUserEmails(); backfillDefaultUserEmails();
removeLegacyPortalLoginField(); removeLegacyPortalLoginField();
migrateCustomerNumbers();
migrateCatalogAccountIds();
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN); ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
} }
public Farmer requireActiveFarmer(String businessKey) { public Farmer requireActiveFarmer(String actorId, String businessKey) {
return farmerRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveFarmersForActor(actor).stream()
.filter(farmer -> farmer.businessKey().equals(businessKey)) .filter(farmer -> farmer.businessKey().equals(businessKey))
.findFirst() .findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
} }
public Map<String, PathogenCatalogItem> activePathogensByBusinessKey() { public Map<String, PathogenCatalogItem> activePathogensByBusinessKey(String actorId) {
return pathogenRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActivePathogensForActor(actor).stream()
.collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity())); .collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity()));
} }
public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey() { public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey(String actorId) {
return antibioticRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveAntibioticsForActor(actor).stream()
.collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity())); .collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity()));
} }
public Map<String, MedicationCatalogItem> activeMedicationsByBusinessKey() { public Map<String, MedicationCatalogItem> activeMedicationsByBusinessKey(String actorId) {
return medicationRepository.findByActiveTrueOrderByNameAsc().stream() AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
return listActiveMedicationsForActor(actor).stream()
.collect(Collectors.toMap(MedicationCatalogItem::businessKey, Function.identity())); .collect(Collectors.toMap(MedicationCatalogItem::businessKey, Function.identity()));
} }
@@ -646,8 +777,15 @@ public class CatalogService {
return new FarmerRow( return new FarmerRow(
farmer.id(), farmer.id(),
farmer.businessKey(), farmer.businessKey(),
farmer.name(), farmer.customerNumber(),
farmer.companyName(),
farmer.contactPerson(),
farmer.street(),
farmer.houseNumber(),
farmer.postalCode(),
farmer.city(),
farmer.email(), farmer.email(),
farmer.phoneNumber(),
farmer.active(), farmer.active(),
farmer.updatedAt() farmer.updatedAt()
); );
@@ -700,14 +838,19 @@ public class CatalogService {
user.city(), user.city(),
user.email(), user.email(),
user.phoneNumber(), user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.active(), user.active(),
normalizeStoredRole(user.role()), normalizeStoredRole(user.role()),
user.customerNumber(),
user.updatedAt() user.updatedAt()
); );
} }
private FarmerOption toFarmerOption(Farmer farmer) { private FarmerOption toFarmerOption(Farmer farmer) {
return new FarmerOption(farmer.businessKey(), farmer.name(), farmer.email()); return new FarmerOption(farmer.businessKey(), farmer.companyName(), farmer.contactPerson(), farmer.email());
} }
private MedicationOption toMedicationOption(MedicationCatalogItem item) { private MedicationOption toMedicationOption(MedicationCatalogItem item) {
@@ -735,7 +878,12 @@ public class CatalogService {
user.city(), user.city(),
user.email(), user.email(),
user.phoneNumber(), 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.city(),
user.email(), user.email(),
user.phoneNumber(), user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.passwordHash(), user.passwordHash(),
user.active(), user.active(),
normalizeStoredRole(user.role()), normalizeStoredRole(user.role()),
user.nextSampleNumber(),
user.customerNumber(),
user.createdAt(), user.createdAt(),
now now
))); )));
} }
private void migrateCustomerNumbers() {
LocalDateTime now = LocalDateTime.now();
appUserRepository.findAll().stream()
.filter(user -> isBlank(user.customerNumber()))
.forEach(user -> appUserRepository.save(new AppUser(
user.id(),
user.accountId(),
user.primaryUser(),
user.displayName(),
user.companyName(),
user.address(),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.passwordHash(),
user.active(),
user.role(),
user.nextSampleNumber(),
generateNextCustomerNumber(),
user.createdAt(),
now
)));
}
private String generateNextCustomerNumber() {
List<AppUser> usersWithNumbers = appUserRepository.findTopByCustomerNumberExistsOrderByCustomerNumberDesc();
int nextNumber = 1000;
if (!usersWithNumbers.isEmpty()) {
String highestNumber = usersWithNumbers.get(0).customerNumber();
if (highestNumber != null && highestNumber.startsWith("K")) {
try {
int highest = Integer.parseInt(highestNumber.substring(1));
nextNumber = highest + 1;
} catch (NumberFormatException e) {
// Fallback to 1000 if parsing fails
}
}
}
// Ensure uniqueness in case of concurrent operations
while (appUserRepository.findByCustomerNumber("K" + nextNumber).isPresent()) {
nextNumber++;
}
return "K" + nextNumber;
}
private void ensureDefaultUser( private void ensureDefaultUser(
String displayName, String displayName,
String email, String email,
@@ -861,9 +1067,15 @@ public class CatalogService {
null, null,
email, email,
null, null,
null,
null,
null,
null,
passwordEncoder.encode(rawPassword), passwordEncoder.encode(rawPassword),
true, true,
role, role,
100000L,
generateNextCustomerNumber(),
now, now,
now now
)); ));
@@ -889,6 +1101,93 @@ public class CatalogService {
backfillDefaultUserEmail("admin", "admin@muh.local"); backfillDefaultUserEmail("admin", "admin@muh.local");
} }
private void migrateCatalogAccountIds() {
// Finde den ersten Admin-Benutzer als Fallback
String defaultAccountId = appUserRepository.findAll().stream()
.filter(user -> user.role() == UserRole.ADMIN)
.findFirst()
.map(AppUser::id)
.orElse(null);
if (defaultAccountId == null) {
return;
}
LocalDateTime now = LocalDateTime.now();
// Migriere Farmers ohne accountId oder mit altem Schema
farmerRepository.findAll().stream()
.filter(farmer -> isBlank(farmer.accountId()) || isBlank(farmer.companyName()))
.forEach(farmer -> {
// Wenn companyName fehlt, nutze businessKey als Fallback
String companyName = isBlank(farmer.companyName()) ? farmer.businessKey() : farmer.companyName();
farmerRepository.save(new Farmer(
farmer.id(),
isBlank(farmer.accountId()) ? defaultAccountId : farmer.accountId(),
farmer.businessKey(),
null, // customerNumber
companyName,
null, // contactPerson
null, // street
null, // houseNumber
null, // postalCode
null, // city
farmer.email(),
null, // phoneNumber
farmer.active(),
farmer.supersedesId(),
farmer.createdAt(),
now
));
});
// Migriere Medications ohne accountId
medicationRepository.findAll().stream()
.filter(med -> isBlank(med.accountId()))
.forEach(med -> medicationRepository.save(new MedicationCatalogItem(
med.id(),
defaultAccountId,
med.businessKey(),
med.name(),
med.category(),
med.active(),
med.supersedesId(),
med.createdAt(),
now
)));
// Migriere Pathogens ohne accountId
pathogenRepository.findAll().stream()
.filter(pathogen -> isBlank(pathogen.accountId()))
.forEach(pathogen -> pathogenRepository.save(new PathogenCatalogItem(
pathogen.id(),
defaultAccountId,
pathogen.businessKey(),
pathogen.code(),
pathogen.name(),
pathogen.kind(),
pathogen.active(),
pathogen.supersedesId(),
pathogen.createdAt(),
now
)));
// Migriere Antibiotics ohne accountId
antibioticRepository.findAll().stream()
.filter(antibiotic -> isBlank(antibiotic.accountId()))
.forEach(antibiotic -> antibioticRepository.save(new AntibioticCatalogItem(
antibiotic.id(),
defaultAccountId,
antibiotic.businessKey(),
antibiotic.code(),
antibiotic.name(),
antibiotic.active(),
antibiotic.supersedesId(),
antibiotic.createdAt(),
now
)));
}
private void backfillDefaultUserEmail(String legacyPortalLogin, String email) { private void backfillDefaultUserEmail(String legacyPortalLogin, String email) {
mongoTemplate.updateMulti( mongoTemplate.updateMulti(
new Query(new Criteria().andOperator( new Query(new Criteria().andOperator(
@@ -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) { public record MedicationOption(String businessKey, String name, MedicationCategory category) {
@@ -987,21 +1286,45 @@ public class CatalogService {
String city, String city,
String email, String email,
String phoneNumber, String phoneNumber,
UserRole role String accountHolder,
String bankName,
String iban,
String bic,
UserRole role,
String customerNumber
) { ) {
} }
public record FarmerRow( public record FarmerRow(
String id, String id,
String businessKey, String businessKey,
String name, String customerNumber,
String companyName,
String contactPerson,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber,
boolean active, boolean active,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
} }
public record FarmerMutation(String id, String name, String email, boolean active) { public record FarmerMutation(
String id,
String customerNumber,
String companyName,
String contactPerson,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
boolean active
) {
} }
public record MedicationRow( public record MedicationRow(
@@ -1056,8 +1379,13 @@ public class CatalogService {
String city, String city,
String email, String email,
String phoneNumber, String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
boolean active, boolean active,
UserRole role, UserRole role,
String customerNumber,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
} }
@@ -1073,6 +1401,10 @@ public class CatalogService {
String city, String city,
String email, String email,
String phoneNumber, String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String password, String password,
boolean active, boolean active,
UserRole role 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, Long sampleNumber,
LocalDate date LocalDate date
) { ) {
List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary().farmers().stream() List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary(actorId).farmers().stream()
.filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT))) .filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.companyName().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT)))
.toList(); .toList();
List<PortalSampleRow> sampleRows; List<PortalSampleRow> sampleRows;

View File

@@ -4,6 +4,7 @@ import de.svencarstensen.muh.domain.AntibiogramEntry;
import de.svencarstensen.muh.domain.AppUser; import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.PathogenCatalogItem; import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind; import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.Pretreatment;
import de.svencarstensen.muh.domain.QuarterAntibiogram; import de.svencarstensen.muh.domain.QuarterAntibiogram;
import de.svencarstensen.muh.domain.QuarterFinding; import de.svencarstensen.muh.domain.QuarterFinding;
import de.svencarstensen.muh.domain.QuarterKey; import de.svencarstensen.muh.domain.QuarterKey;
@@ -22,6 +23,8 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@@ -67,7 +70,7 @@ public class SampleService {
.filter(sample -> !sample.completedAt().isBefore(today.atStartOfDay())) .filter(sample -> !sample.completedAt().isBefore(today.atStartOfDay()))
.filter(sample -> sample.completedAt().isBefore(today.plusDays(1).atStartOfDay())) .filter(sample -> sample.completedAt().isBefore(today.plusDays(1).atStartOfDay()))
.count(); .count();
return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent); return new DashboardOverview(nextSampleNumber(actorId), openCount, completedToday, recent);
} }
public LookupResult lookup(String actorId, long sampleNumber) { public LookupResult lookup(String actorId, long sampleNumber) {
@@ -99,16 +102,29 @@ public class SampleService {
public SampleDetail createSample(String actorId, RegistrationRequest request) { public SampleDetail createSample(String actorId, RegistrationRequest request) {
AppUser actor = requireActor(actorId); AppUser actor = requireActor(actorId);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary(actorId).farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
.findFirst() .findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
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( Sample sample = new Sample(
null, null,
nextSampleNumber(), sampleNumber,
farmer.businessKey(), farmer.businessKey(),
farmer.name(), farmer.companyName(),
farmer.email(), farmer.email(),
request.cowNumber().trim(), request.cowNumber().trim(),
blankToNull(request.cowName()), blankToNull(request.cowName()),
@@ -126,7 +142,10 @@ public class SampleService {
null, null,
authorizationService.accountId(actor), authorizationService.accountId(actor),
request.userCode(), request.userCode(),
request.userDisplayName() request.userDisplayName(),
pretreatment,
parseClinicalExamDate(request.clinicalExamDate()),
blankToNull(request.internalNote())
); );
return toDetail(sampleRepository.save(sample)); 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"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden");
} }
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream() CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary(actorId).farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey())) .filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
.findFirst() .findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
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( Sample saved = sampleRepository.save(new Sample(
existing.id(), existing.id(),
existing.sampleNumber(), existing.sampleNumber(),
farmer.businessKey(), farmer.businessKey(),
farmer.name(), farmer.companyName(),
farmer.email(), farmer.email(),
request.cowNumber().trim(), request.cowNumber().trim(),
blankToNull(request.cowName()), blankToNull(request.cowName()),
@@ -165,7 +196,14 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), 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); return toDetail(saved);
@@ -182,7 +220,7 @@ public class SampleService {
current.put(quarter.quarterKey(), quarter); current.put(quarter.quarterKey(), quarter);
} }
Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey(); Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey(actorId);
List<QuarterFinding> updatedQuarters = new ArrayList<>(); List<QuarterFinding> updatedQuarters = new ArrayList<>();
for (AnamnesisQuarterRequest quarterRequest : request.quarters()) { for (AnamnesisQuarterRequest quarterRequest : request.quarters()) {
QuarterFinding base = current.get(quarterRequest.quarterKey()); QuarterFinding base = current.get(quarterRequest.quarterKey());
@@ -232,7 +270,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
return toDetail(saved); return toDetail(saved);
} }
@@ -243,7 +284,7 @@ public class SampleService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden");
} }
Map<String, de.svencarstensen.muh.domain.AntibioticCatalogItem> antibiotics = catalogService.activeAntibioticsByBusinessKey(); Map<String, de.svencarstensen.muh.domain.AntibioticCatalogItem> antibiotics = catalogService.activeAntibioticsByBusinessKey(actorId);
Map<QuarterKey, QuarterAntibiogram> groups = new HashMap<>(); Map<QuarterKey, QuarterAntibiogram> groups = new HashMap<>();
Map<QuarterKey, QuarterFinding> quartersByKey = existing.quarters().stream() Map<QuarterKey, QuarterFinding> quartersByKey = existing.quarters().stream()
.collect(java.util.stream.Collectors.toMap(QuarterFinding::quarterKey, quarter -> quarter)); .collect(java.util.stream.Collectors.toMap(QuarterFinding::quarterKey, quarter -> quarter));
@@ -323,7 +364,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
return toDetail(saved); return toDetail(saved);
} }
@@ -333,7 +377,7 @@ public class SampleService {
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) { if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
TherapyRecommendation previous = existing.therapyRecommendation(); TherapyRecommendation previous = existing.therapyRecommendation();
TherapyRecommendation updated = previous == null TherapyRecommendation updated = previous == null
? 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( : new TherapyRecommendation(
previous.continueStarted(), previous.continueStarted(),
previous.switchTherapy(), previous.switchTherapy(),
@@ -348,7 +392,15 @@ public class SampleService {
previous.dryAntibioticKeys(), previous.dryAntibioticKeys(),
previous.dryAntibioticNames(), previous.dryAntibioticNames(),
previous.farmerNote(), 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( return toDetail(sampleRepository.save(new Sample(
existing.id(), existing.id(),
@@ -372,7 +424,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), 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"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Therapie kann nicht bearbeitet werden");
} }
Map<String, de.svencarstensen.muh.domain.MedicationCatalogItem> medications = catalogService.activeMedicationsByBusinessKey(); Map<String, de.svencarstensen.muh.domain.MedicationCatalogItem> medications = catalogService.activeMedicationsByBusinessKey(actorId);
TherapyRecommendation therapy = new TherapyRecommendation( TherapyRecommendation therapy = new TherapyRecommendation(
request.continueStarted(), request.continueStarted(),
request.switchTherapy(), request.switchTherapy(),
@@ -395,7 +450,15 @@ public class SampleService {
request.dryAntibioticKeys(), request.dryAntibioticKeys(),
resolveMedicationNames(request.dryAntibioticKeys(), medications), resolveMedicationNames(request.dryAntibioticKeys(), medications),
blankToNull(request.farmerNote()), 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( Sample saved = sampleRepository.save(new Sample(
@@ -420,7 +483,10 @@ public class SampleService {
LocalDateTime.now(), LocalDateTime.now(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
return toDetail(saved); return toDetail(saved);
} }
@@ -449,7 +515,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
} }
@@ -477,7 +546,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
} }
@@ -513,9 +585,49 @@ public class SampleService {
} }
public long nextSampleNumber() { public long nextSampleNumber() {
return sampleRepository.findTopByOrderBySampleNumberDesc() return nextSampleNumber(null);
.map(sample -> sample.sampleNumber() + 1) }
.orElse(100001L);
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) { public Sample loadSampleEntity(String actorId, String id) {
@@ -607,7 +719,10 @@ public class SampleService {
sample.completedAt(), sample.completedAt(),
resolvedAccountId, resolvedAccountId,
sample.createdByUserCode(), sample.createdByUserCode(),
sample.createdByDisplayName() sample.createdByDisplayName(),
sample.pretreatment(),
sample.clinicalExamDate(),
sample.internalNote()
)); ));
} }
} }
@@ -701,7 +816,10 @@ public class SampleService {
SampleWorkflowRules.canEditAnamnesis(sample), SampleWorkflowRules.canEditAnamnesis(sample),
SampleWorkflowRules.canEditAntibiogram(sample), SampleWorkflowRules.canEditAntibiogram(sample),
SampleWorkflowRules.canEditTherapy(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.dryAntibioticKeys(),
therapy.dryAntibioticNames(), therapy.dryAntibioticNames(),
therapy.farmerNote(), 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> dryAntibioticKeys,
List<String> dryAntibioticNames, List<String> dryAntibioticNames,
String farmerNote, 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 anamnesisEditable,
boolean antibiogramEditable, boolean antibiogramEditable,
boolean therapyEditable, boolean therapyEditable,
boolean completed boolean completed,
Pretreatment pretreatment,
LocalDate clinicalExamDate,
String internalNote
) { ) {
} }
@@ -885,7 +1022,13 @@ public class SampleService {
SamplingMode samplingMode, SamplingMode samplingMode,
List<QuarterKey> flaggedQuarters, List<QuarterKey> flaggedQuarters,
String userCode, 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> drySealerKeys,
List<String> dryAntibioticKeys, List<String> dryAntibioticKeys,
String farmerNote, 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

@@ -0,0 +1,25 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.AdminStatisticsService;
import de.svencarstensen.muh.web.dto.AdminStatistics;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
private final AdminStatisticsService adminStatisticsService;
public AdminController(AdminStatisticsService adminStatisticsService) {
this.adminStatisticsService = adminStatisticsService;
}
@GetMapping("/statistics")
public AdminStatistics getStatistics() {
return adminStatisticsService.getStatistics();
}
}

View File

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

View File

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

View File

@@ -54,7 +54,13 @@ public class SampleController {
request.samplingMode(), request.samplingMode(),
request.flaggedQuarters(), request.flaggedQuarters(),
deriveUserLabel(user.displayName()), 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") @PostMapping("/register")
public CatalogService.SessionResponse register(@RequestBody RegistrationRequest request) { public CatalogService.RegistrationResponse register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation( return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(), request.companyName(),
request.street(), 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

@@ -0,0 +1,17 @@
package de.svencarstensen.muh.web.dto;
import java.util.List;
public record AdminStatistics(
long totalVets,
long totalSamples,
List<VetSampleStats> samplesPerVet
) {
public record VetSampleStats(
String userId,
String displayName,
String companyName,
long sampleCount
) {
}
}

View File

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

@@ -8,7 +8,9 @@
"name": "muh-frontend", "name": "muh-frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-router-dom": "6.23.1" "react-router-dom": "6.23.1"
}, },
@@ -745,6 +747,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.16.1", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz",
@@ -1290,6 +1298,18 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1538,6 +1558,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",

View File

@@ -9,7 +9,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-router-dom": "6.23.1" "react-router-dom": "6.23.1"
}, },

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { SessionProvider, useSession } from "./lib/session"; import { SessionProvider, useSession } from "./lib/session";
import AppShell from "./layout/AppShell"; import AppShell from "./layout/AppShell";
import HomePage from "./pages/HomePage"; import HomePage from "./pages/HomePage";
import AdminDashboardPage from "./pages/AdminDashboardPage";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import SampleRegistrationPage from "./pages/SampleRegistrationPage"; import SampleRegistrationPage from "./pages/SampleRegistrationPage";
import AnamnesisPage from "./pages/AnamnesisPage"; import AnamnesisPage from "./pages/AnamnesisPage";
@@ -14,6 +15,10 @@ import SearchFarmerPage from "./pages/SearchFarmerPage";
import SearchCalendarPage from "./pages/SearchCalendarPage"; import SearchCalendarPage from "./pages/SearchCalendarPage";
import UserManagementPage from "./pages/UserManagementPage"; import UserManagementPage from "./pages/UserManagementPage";
import ReportTemplatePage from "./pages/ReportTemplatePage"; 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() { function ProtectedRoutes() {
const { user, ready } = useSession(); const { user, ready } = useSession();
@@ -30,7 +35,8 @@ function ProtectedRoutes() {
return ( return (
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={isAdmin ? <AdminDashboardPage /> : <HomePage />} />
<Route path="/admin/dashboard" element={<AdminDashboardPage />} />
<Route path="/samples/new" element={<SampleRegistrationPage />} /> <Route path="/samples/new" element={<SampleRegistrationPage />} />
<Route path="/samples/:sampleId/registration" element={<SampleRegistrationPage />} /> <Route path="/samples/:sampleId/registration" element={<SampleRegistrationPage />} />
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} /> <Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
@@ -43,6 +49,10 @@ function ProtectedRoutes() {
<Route path="/admin/medikamente" element={<AdministrationPage />} /> <Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} /> <Route path="/admin/erreger" element={<AdministrationPage />} />
<Route path="/admin/antibiogramm" 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 />} /> <Route path="/search" element={<Navigate to="/search/probe" replace />} />
<Route path="/search/probe" element={<SearchPage />} /> <Route path="/search/probe" element={<SearchPage />} />
<Route path="/search/landwirt" element={<SearchFarmerPage />} /> <Route path="/search/landwirt" element={<SearchFarmerPage />} />

View File

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

View File

@@ -3,12 +3,17 @@ import { useSession } from "../lib/session";
const PAGE_TITLES: Record<string, string> = { const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite", "/home": "Startseite",
"/admin/dashboard": "Admin Dashboard",
"/samples/new": "Neuanlage einer Probe", "/samples/new": "Neuanlage einer Probe",
"/portal": "MUH-Portal", "/portal": "MUH-Portal",
"/report-template": "Bericht", "/report-template": "Bericht",
"/admin/stammdaten": "Meine Stammdaten",
"/admin/preistabelle": "Preistabelle",
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
"/admin/rechnung/template": "Rechnungsvorlage",
}; };
function resolvePageTitle(pathname: string) { function resolvePageTitle(pathname: string, isAdmin: boolean) {
if (pathname.includes("/anamnesis")) { if (pathname.includes("/anamnesis")) {
return "Anamnese"; return "Anamnese";
} }
@@ -21,29 +26,8 @@ function resolvePageTitle(pathname: string) {
if (pathname.includes("/registration")) { if (pathname.includes("/registration")) {
return "Probe bearbeiten"; return "Probe bearbeiten";
} }
if (pathname.startsWith("/admin/landwirte")) {
return "Die Verwaltung der Landwirte";
}
if (pathname.startsWith("/admin/benutzer")) { if (pathname.startsWith("/admin/benutzer")) {
return "Verwaltung | Benutzer"; return isAdmin ? "Benutzerfreigabe" : "Verwaltung | Benutzer";
}
if (pathname.startsWith("/admin/medikamente")) {
return "Die Verwaltung der Medikamente";
}
if (pathname.startsWith("/admin/erreger")) {
return "Die Verwaltung der Erreger";
}
if (pathname.startsWith("/admin/antibiogramm")) {
return "Die Verwaltung der Antibiogramme";
}
if (pathname.startsWith("/search/landwirt")) {
return "Suche | Landwirt";
}
if (pathname.startsWith("/search/probe")) {
return "Suche | Probe";
}
if (pathname.startsWith("/search/kalendar")) {
return "Suche | Kalendar";
} }
return PAGE_TITLES[pathname] ?? "MUH App"; return PAGE_TITLES[pathname] ?? "MUH App";
} }
@@ -57,17 +41,56 @@ export default function AppShell() {
<div className="app-shell"> <div className="app-shell">
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar__brand"> <div className="sidebar__brand">
<div className="sidebar__logo">MUH</div> <div className="sidebar__logo">
MUH <span className="sidebar__version">({__APP_VERSION__})</span>
</div>
</div> </div>
<nav className="sidebar__nav"> <nav className="sidebar__nav">
{user?.role === "ADMIN" ? ( {user?.role === "ADMIN" ? (
<>
<NavLink <NavLink
to="/portal" to="/admin/dashboard"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`} className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
> >
Benutzerverwaltung Dashboard
</NavLink> </NavLink>
<div className="nav-group">
<div className="nav-group__label">Benutzerverwaltung</div>
<div className="nav-subnav">
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Freigabe / Sperre
</NavLink>
</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">
<NavLink to="/admin/rechnung/verwalten" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Verwalten
</NavLink>
<NavLink to="/admin/rechnung/template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Template
</NavLink>
</div>
</div>
</>
) : ( ) : (
<> <>
<NavLink to="/home" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}> <NavLink to="/home" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
@@ -149,7 +172,7 @@ export default function AppShell() {
<div className="shell-main"> <div className="shell-main">
<header className="topbar"> <header className="topbar">
<div className="topbar__headline"> <div className="topbar__headline">
<h2>{resolvePageTitle(location.pathname)}</h2> <h2>{resolvePageTitle(location.pathname, user?.role === "ADMIN")}</h2>
</div> </div>
</header> </header>

View File

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

View File

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

View File

@@ -0,0 +1,201 @@
import { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type TooltipItem,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { apiGet } from "../lib/api";
// Chart.js Komponenten registrieren
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
interface VetSampleStats {
userId: string;
displayName: string;
companyName: string | null;
sampleCount: number;
}
interface AdminStatistics {
totalVets: number;
totalSamples: number;
samplesPerVet: VetSampleStats[];
}
export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadStats() {
try {
const response = await apiGet<AdminStatistics>("/admin/statistics");
setStats(response);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
void loadStats();
}, []);
// Chart Daten vorbereiten
const chartData = {
labels: stats?.samplesPerVet.map((vet) => vet.displayName) || [],
datasets: [
{
label: "Anzahl Proben",
data: stats?.samplesPerVet.map((vet) => vet.sampleCount) || [],
backgroundColor: "rgba(90, 123, 168, 0.8)",
borderColor: "rgba(90, 123, 168, 1)",
borderWidth: 1,
borderRadius: 8,
borderSkipped: false,
},
],
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
title: {
display: true,
text: "Proben pro Tierarzt",
font: {
size: 16,
weight: "bold" as const,
},
padding: {
top: 10,
bottom: 20,
},
color: "#1d2428",
},
tooltip: {
backgroundColor: "rgba(29, 36, 40, 0.9)",
padding: 12,
cornerRadius: 8,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
callbacks: {
label: (context: TooltipItem<"bar">) => {
const value = context.parsed.y as number | null;
return `${value ?? 0} Proben`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: "#666",
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
title: {
display: true,
text: "Anzahl Proben",
color: "#666",
font: {
size: 12,
},
},
},
x: {
ticks: {
color: "#666",
maxRotation: 45,
minRotation: 45,
},
grid: {
display: false,
},
title: {
display: true,
text: "Tierarzt",
color: "#666",
font: {
size: 12,
},
},
},
},
};
return (
<div className="page-stack">
{/* Header Bereich */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Administration</p>
<h3>Administrator Dashboard</h3>
<p className="muted-text">
Übersicht über Tierärzte und Proben im System.
</p>
</div>
</section>
{error ? (
<div className="alert alert--error">{error}</div>
) : null}
{/* Statistik-Karten */}
<section className="metrics-grid admin-metrics">
<article className="metric-card metric-card--primary">
<span className="metric-card__label">Tierärzte</span>
<strong className="metric-card__value--large">
{loading ? "..." : stats?.totalVets ?? 0}
</strong>
</article>
<article className="metric-card metric-card--secondary">
<span className="metric-card__label">Proben insgesamt</span>
<strong className="metric-card__value--large">
{loading ? "..." : stats?.totalSamples ?? 0}
</strong>
</article>
</section>
{/* Chart Bereich */}
<section className="section-card">
<div className="chart-container">
{loading ? (
<div className="empty-state">Chart wird geladen...</div>
) : stats?.samplesPerVet.length === 0 ? (
<div className="empty-state">Noch keine Proben vorhanden.</div>
) : (
<Bar data={chartData} options={chartOptions} />
)}
</div>
</section>
</div>
);
}

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

View File

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

View File

@@ -0,0 +1,640 @@
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;
invoiceNumber: string;
customerName: string;
invoiceDate: string;
dueDate: string;
totalAmount: number;
status: "DRAFT" | "SENT" | "PAID" | "OVERDUE" | "CANCELLED";
}
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",
PAID: "Bezahlt",
OVERDUE: "Überfällig",
CANCELLED: "Storniert",
};
const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
DRAFT: "status-badge--draft",
SENT: "status-badge--sent",
PAID: "status-badge--success",
OVERDUE: "status-badge--error",
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);
setError(null);
apiGet<InvoiceOverview>("/admin/invoices")
.then((response) => {
if (!cancelled) {
setInvoices(response.invoices);
}
})
.catch(() => {
if (!cancelled) {
setInvoices([]);
setError(null);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
// 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",
currency: "EUR",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
}).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">
<div>
<p className="eyebrow">Rechnungsverwaltung</p>
<h3>Übersicht aller Rechnungen</h3>
<p className="muted-text">
Hier können Sie alle erstellten Rechnungen einsehen, deren Status verfolgen
und geplante Rechnungen verwalten.
</p>
</div>
{error ? <div className="alert alert--error">{error}</div> : null}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Rechnungen</p>
<h3>Rechnungsliste</h3>
</div>
<div className="page-actions">
<button
type="button"
className="accent-button"
onClick={handleNewInvoice}
>
Neue Rechnung
</button>
</div>
</div>
{loading ? (
<div className="empty-state">Rechnungen werden geladen...</div>
) : invoices.length === 0 ? (
<div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text">
Erstellen Sie Ihre erste Rechnung mit dem Button "Neue Rechnung".
</p>
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Rechnungsnr.</th>
<th>Kunde</th>
<th>Rechnungsdatum</th>
<th>Fällig am</th>
<th>Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td>{invoice.invoiceNumber}</td>
<td>{invoice.customerName}</td>
<td>{formatDate(invoice.invoiceDate)}</td>
<td>{formatDate(invoice.dueDate)}</td>
<td>{formatCurrency(invoice.totalAmount)}</td>
<td>
<span className={`status-badge ${STATUS_CLASSES[invoice.status]}`}>
{STATUS_LABELS[invoice.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Zusammenfassung</p>
<h3>Statistik</h3>
</div>
</div>
<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}
</td>
</tr>
<tr>
<td style={{ fontWeight: 500 }}>Überfällig</td>
<td style={{ textAlign: "right" }}>
{invoices.filter((i) => i.status === "OVERDUE").length}
</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)
)}
</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

@@ -1,8 +1,14 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { apiPost } from "../lib/api"; import { apiPost } from "../lib/api";
import { useSession } from "../lib/session"; import { useSession } from "../lib/session";
import type { SessionResponse } from "../lib/types"; import type { SessionResponse } from "../lib/types";
interface RegistrationResponse {
userId: string;
email: string;
}
type FeedbackState = type FeedbackState =
| { type: "error"; text: string } | { type: "error"; text: string }
| { type: "success"; text: string } | { type: "success"; text: string }
@@ -28,6 +34,7 @@ export default function LoginPage() {
}); });
const [feedback, setFeedback] = useState<FeedbackState>(null); const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setSession } = useSession(); const { setSession } = useSession();
const navigate = useNavigate();
function unlockLoginInputs() { function unlockLoginInputs() {
setLoginInputsUnlocked(true); setLoginInputsUnlocked(true);
@@ -50,6 +57,8 @@ export default function LoginPage() {
password, password,
}); });
setSession(response); setSession(response);
// Admin zum Dashboard, Kunden zur Startseite
navigate(response.user.role === "ADMIN" ? "/admin/dashboard" : "/home");
} catch (loginError) { } catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message }); setFeedback({ type: "error", text: (loginError as Error).message });
} }
@@ -82,12 +91,24 @@ export default function LoginPage() {
setFeedback(null); setFeedback(null);
const { passwordConfirmation, ...registrationPayload } = registration; const { passwordConfirmation, ...registrationPayload } = registration;
void passwordConfirmation; void passwordConfirmation;
const response = await apiPost<SessionResponse>("/session/register", registrationPayload); const response = await apiPost<RegistrationResponse>("/session/register", registrationPayload);
setFeedback({ setFeedback({
type: "success", 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); // Reset registration form and switch back to login
setRegistration({
companyName: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
password: "",
passwordConfirmation: "",
});
setShowRegistration(false);
} catch (registrationError) { } catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message }); setFeedback({ type: "error", text: (registrationError as Error).message });
} }
@@ -173,7 +194,7 @@ export default function LoginPage() {
</div> </div>
</form> </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"> <div className="field-grid">
<label className="field field--wide field--required"> <label className="field field--wide field--required">
<span>Firmenname</span> <span>Firmenname</span>
@@ -238,6 +259,7 @@ export default function LoginPage() {
onChange={(event) => onChange={(event) =>
setRegistration((current) => ({ ...current, email: event.target.value })) setRegistration((current) => ({ ...current, email: event.target.value }))
} }
autoComplete="off"
required required
/> />
</label> </label>
@@ -250,6 +272,7 @@ export default function LoginPage() {
setRegistration((current) => ({ ...current, phoneNumber: event.target.value })) setRegistration((current) => ({ ...current, phoneNumber: event.target.value }))
} }
placeholder="z. B. 04531 181424" placeholder="z. B. 04531 181424"
autoComplete="off"
required required
/> />
</label> </label>
@@ -261,6 +284,7 @@ export default function LoginPage() {
onChange={(event) => onChange={(event) =>
setRegistration((current) => ({ ...current, password: event.target.value })) setRegistration((current) => ({ ...current, password: event.target.value }))
} }
autoComplete="new-password"
required required
/> />
</label> </label>
@@ -284,6 +308,7 @@ export default function LoginPage() {
passwordConfirmation: event.target.value, passwordConfirmation: event.target.value,
})) }))
} }
autoComplete="new-password"
required required
/> />
</label> </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); setResizingElementId(null);
setTemplateUpdatedAt(null); setTemplateUpdatedAt(null);
setTemplateError(null); setTemplateError(null);
setIsTemplateApiAvailable(false);
return; return;
} }
setTemplateError((error as Error).message); setTemplateError((error as Error).message);

View File

@@ -18,6 +18,14 @@ const QUARTERS: { key: QuarterKey; label: string }[] = [
{ key: "RIGHT_REAR", label: "Hinten rechts" }, { 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() { export default function SampleRegistrationPage() {
const { sampleId } = useParams(); const { sampleId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -32,6 +40,15 @@ export default function SampleRegistrationPage() {
const [sampleKind, setSampleKind] = useState<SampleKind>("LACTATION"); const [sampleKind, setSampleKind] = useState<SampleKind>("LACTATION");
const [samplingMode, setSamplingMode] = useState<SamplingMode>("SINGLE_SITE"); const [samplingMode, setSamplingMode] = useState<SamplingMode>("SINGLE_SITE");
const [flaggedQuarters, setFlaggedQuarters] = useState<QuarterKey[]>([]); 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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@@ -55,6 +72,17 @@ export default function SampleRegistrationPage() {
setFlaggedQuarters( setFlaggedQuarters(
sample.quarters.filter((quarter) => quarter.flagged).map((quarter) => quarter.quarterKey), 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 { } else {
const dashboard = await apiGet<DashboardOverview>("/dashboard"); const dashboard = await apiGet<DashboardOverview>("/dashboard");
setSampleNumber(dashboard.nextSampleNumber); 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>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setShowValidation(true); setShowValidation(true);
@@ -101,6 +136,13 @@ export default function SampleRegistrationPage() {
flaggedQuarters, flaggedQuarters,
userCode: user.displayName, userCode: user.displayName,
userDisplayName: 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 { try {
@@ -126,7 +168,7 @@ export default function SampleRegistrationPage() {
<p className="eyebrow">Neuanlage</p> <p className="eyebrow">Neuanlage</p>
<h3>Probe {sampleNumber ?? "..."}</h3> <h3>Probe {sampleNumber ?? "..."}</h3>
<p className="muted-text"> <p className="muted-text">
Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich ueber den Die Probenummer wird fortlaufend vergeben. Trockensteller lassen sich über den
Schalter Trockenstellerprobe markieren. Schalter Trockenstellerprobe markieren.
</p> </p>
</div> </div>
@@ -154,7 +196,7 @@ export default function SampleRegistrationPage() {
> >
{catalogs?.farmers.map((farmer) => ( {catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}> <option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name} {farmer.companyName}
</option> </option>
))} ))}
</select> </select>
@@ -258,6 +300,55 @@ export default function SampleRegistrationPage() {
</section> </section>
) : null} ) : 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"> <div className="page-actions">
<button type="submit" className="accent-button" disabled={saving || !editable}> <button type="submit" className="accent-button" disabled={saving || !editable}>
{saving ? "Speichern ..." : "Speichern"} {saving ? "Speichern ..." : "Speichern"}

View File

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

View File

@@ -11,6 +11,12 @@ function medicationOptions(catalogs: ActiveCatalogSummary, category: MedicationC
return catalogs.medications.filter((medication) => medication.category === category); 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() { export default function TherapyPage() {
const { sampleId } = useParams(); const { sampleId } = useParams();
@@ -26,6 +32,15 @@ export default function TherapyPage() {
const [dryAntibioticKeys, setDryAntibioticKeys] = useState<string[]>([]); const [dryAntibioticKeys, setDryAntibioticKeys] = useState<string[]>([]);
const [farmerNote, setFarmerNote] = useState(""); const [farmerNote, setFarmerNote] = useState("");
const [internalNote, setInternalNote] = 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 [message, setMessage] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -51,6 +66,15 @@ export default function TherapyPage() {
setDryAntibioticKeys(sampleResponse.therapy?.dryAntibioticKeys ?? []); setDryAntibioticKeys(sampleResponse.therapy?.dryAntibioticKeys ?? []);
setFarmerNote(sampleResponse.therapy?.farmerNote ?? ""); setFarmerNote(sampleResponse.therapy?.farmerNote ?? "");
setInternalNote(sampleResponse.therapy?.internalNote ?? ""); 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) { } catch (loadError) {
setMessage((loadError as Error).message); setMessage((loadError as Error).message);
} }
@@ -65,6 +89,14 @@ export default function TherapyPage() {
setter(list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]); 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() { async function handleSave() {
if (!sampleId) { if (!sampleId) {
return; return;
@@ -83,6 +115,15 @@ export default function TherapyPage() {
dryAntibioticKeys, dryAntibioticKeys,
farmerNote, farmerNote,
internalNote, 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); setSample(response);
setMessage(response.completed ? "Probe gespeichert und abgeschlossen." : "Aenderung gespeichert."); setMessage(response.completed ? "Probe gespeichert und abgeschlossen." : "Aenderung gespeichert.");
@@ -168,7 +209,35 @@ export default function TherapyPage() {
))} ))}
</div> </div>
{/* In-Udder Details from Lua */}
{!noInUdderSelected && inUdderMedicationKeys.length > 0 && (
<div className="field-grid field-grid--2col section-card__spacer">
<label className="field"> <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> <span>Sonstiges</span>
<textarea <textarea
value={inUdderOther} value={inUdderOther}
@@ -202,7 +271,57 @@ export default function TherapyPage() {
)} )}
</div> </div>
{/* Systemic Details from Lua */}
{!noSystemicSelected && systemicMedicationKeys.length > 0 && (
<div className="field-grid field-grid--2col section-card__spacer">
<label className="field"> <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> <span>Sonstiges</span>
<textarea <textarea
value={systemicOther} value={systemicOther}
@@ -254,6 +373,33 @@ export default function TherapyPage() {
</section> </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"> <section className="form-grid">
<article className="section-card"> <article className="section-card">
<label className="field"> <label className="field">

View File

@@ -1,201 +1,294 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { apiDelete, apiGet, apiPost } from "../lib/api"; import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session"; import { useSession } from "../lib/session";
import type { UserOption, UserRole, UserRow } from "../lib/types"; import type { UserRow } from "../lib/types";
type UserDraft = UserRow & { interface SubUserRow {
password: string; id: string;
passwordRepeat: string; displayName: string;
}; email: string | null;
active: boolean;
function toDraft(user: UserRow): UserDraft { updatedAt: string;
return {
...user,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
};
} }
function emptyUser(): UserDraft { export default function UserManagementPage() {
return { const { user, updateUser } = useSession();
id: "", const [users, setUsers] = useState<SubUserRow[]>([]);
primaryUser: false, 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: "", displayName: "",
companyName: "", companyName: "",
address: "",
street: "", street: "",
houseNumber: "", houseNumber: "",
postalCode: "", postalCode: "",
city: "", city: "",
email: "", email: "",
phoneNumber: "", phoneNumber: "",
password: "", });
passwordRepeat: "", const [savingProfile, setSavingProfile] = useState(false);
active: true,
role: "CUSTOMER", // Initialize profile data from session user
updatedAt: new Date().toISOString(), 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 toDraftFromSession(user: UserOption): UserDraft { function openProfileDialog() {
return { resetProfileData();
id: user.id, setShowProfileForm(true);
primaryUser: user.primaryUser,
displayName: user.displayName,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
active: true,
role: user.role,
updatedAt: new Date().toISOString(),
};
} }
function isAccessDenied(error: unknown): boolean { function closeProfileDialog() {
return error instanceof Error && error.message.trim().toLowerCase() === "access denied"; resetProfileData();
} setShowProfileForm(false);
function toMutation(user: UserDraft) {
return {
id: user.id || null,
displayName: user.displayName,
companyName: user.companyName || null,
address: user.address || null,
street: user.street || null,
houseNumber: user.houseNumber || null,
postalCode: user.postalCode || null,
city: user.city || null,
email: user.email || null,
phoneNumber: user.phoneNumber || null,
password: user.password || null,
active: user.active,
role: user.role,
};
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<UserDraft[]>([]);
const [newUser, setNewUser] = useState<UserDraft>(emptyUser());
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const isAdmin = user?.role === "ADMIN";
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
setUsers(response.map(toDraft));
setMessage(null);
} catch (error) {
if (!isAdmin && user?.primaryUser && isAccessDenied(error)) {
setUsers([toDraftFromSession(user)]);
setMessage(null);
return;
}
throw error;
}
} }
useEffect(() => { useEffect(() => {
void loadUsers().catch((error) => setMessage((error as Error).message)); async function loadUsers() {
}, []); try {
const response = await apiGet<UserRow[]>("/portal/users");
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,
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 {
setLoading(false);
}
}
const primaryUser = useMemo( void loadUsers();
() => users.find((entry) => entry.primaryUser) ?? null, }, [isAdmin]);
[users],
); async function toggleUserStatus(userId: string, newStatus: boolean) {
const secondaryUsers = useMemo( try {
() => users.filter((entry) => !entry.primaryUser), const userToUpdate = users.find((u) => u.id === userId);
[users], if (!userToUpdate) return;
);
await apiPost("/portal/users", {
...userToUpdate,
active: newStatus,
});
function updateExistingUser(userId: string, patch: Partial<UserDraft>) {
setUsers((current) => setUsers((current) =>
current.map((entry) => (entry.id === userId ? { ...entry, ...patch } : entry)), current.map((u) => (u.id === userId ? { ...u, active: newStatus } : u))
); );
}
async function saveUser(draft: UserDraft) { setMessage(
setShowValidation(true); `Benutzer "${userToUpdate.displayName}" wurde ${
if (!draft.displayName.trim()) { newStatus ? "freigegeben" : "gesperrt"
setMessage("Bitte alle Pflichtfelder ausfuellen."); }.`
return;
}
try {
const saved = await apiPost<UserRow>("/portal/users", toMutation(draft));
setUsers((current) =>
current.map((entry) => (entry.id === draft.id ? toDraft(saved) : entry)),
); );
setMessage(draft.primaryUser ? "Stammdaten gespeichert." : "Benutzer gespeichert.");
setTimeout(() => setMessage(null), 3000);
} catch (error) { } catch (error) {
setMessage((error as Error).message); setMessage((error as Error).message);
} }
} }
async function createUser(event: FormEvent<HTMLFormElement>) { async function handleCreateUser(e: React.FormEvent) {
event.preventDefault(); e.preventDefault();
setShowValidation(true); if (!newUserName.trim() || !newUserEmail.trim() || !newUserPassword.trim()) {
if (!newUser.displayName.trim() || !(newUser.email ?? "").trim() || !newUser.password.trim()) { setMessage("Bitte alle Felder ausfüllen.");
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return; return;
} }
if (newUser.password !== newUser.passwordRepeat) { if (newUserPassword !== newUserPasswordConfirm) {
setMessage("Die Passwoerter stimmen nicht ueberein."); setMessage("Die Passwörter stimmen nicht überein.");
return; return;
} }
setCreating(true);
try { try {
await apiPost<UserRow>("/portal/users", toMutation(newUser)); await apiPost("/portal/users", {
setNewUser(emptyUser()); displayName: newUserName.trim(),
setMessage("Benutzer angelegt."); email: newUserEmail.trim(),
await loadUsers(); 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) { } catch (error) {
setMessage((error as Error).message); setMessage((error as Error).message);
} finally {
setCreating(false);
} }
} }
async function removeUser(userId: string) { 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 { try {
await apiDelete(`/portal/users/${userId}`); const response = await apiPost<UserRow>("/portal/users", {
setUsers((current) => current.filter((entry) => entry.id !== userId)); id: user?.id,
setMessage("Benutzer geloescht."); displayName: profileData.displayName.trim(),
void loadUsers().catch(() => undefined); 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) { } catch (error) {
setMessage((error as Error).message); setMessage((error as Error).message);
} finally {
setSavingProfile(false);
} }
} }
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
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 und Hauptbenutzer.
</div>
</section>
</div>
);
}
return ( return (
<div className="page-stack"> <div className="page-stack">
<section className="section-card section-card--hero"> {/* Header */}
<section className="hero-card admin-hero">
<div> <div>
<p className="eyebrow">Verwaltung</p> <p className="eyebrow">Benutzerverwaltung</p>
<h3>Benutzer und Stammdaten</h3> <h3>{isAdmin ? "Hauptnutzer freigeben oder sperren" : "Unterbenutzer verwalten"}</h3>
<p className="muted-text"> <p className="muted-text">
Hier pflegen Sie den Hauptbenutzer Ihres Kontos und legen weitere Benutzer an. {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> </p>
</div> </div>
</section>
{/* Status-Meldung */}
{message ? ( {message ? (
<div <div
className={ className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht") message.includes("freigegeben") ||
message.includes("gesperrt") ||
message.includes("erstellt") ||
message.includes("aktualisiert")
? "alert alert--success" ? "alert alert--success"
: "alert alert--error" : "alert alert--error"
} }
@@ -203,268 +296,81 @@ export default function UserManagementPage() {
{message} {message}
</div> </div>
) : null} ) : null}
</section>
{primaryUser ? ( {/* Own Profile Form (nur für Hauptbenutzer) */}
{isPrimaryUser && !isAdmin && (
<section className="section-card"> <section className="section-card">
<div className="section-card__header"> <div className="section-card__header user-management-page__profile-header">
<div> <div>
<p className="eyebrow">Hauptbenutzer</p> <p className="eyebrow">Meine Stammdaten</p>
<h3>Stammdaten bearbeiten</h3> <h3>{user?.displayName}</h3>
</div> </div>
<div className="info-chip">{primaryUser.email ?? primaryUser.displayName}</div> <button
</div> type="button"
className="accent-button"
<div className={`field-grid ${showValidation ? "show-validation" : ""}`}> onClick={openProfileDialog}
<label className="field field--required"> >
<span>Name</span> Bearbeiten
<input
required
value={primaryUser.displayName}
onChange={(event) =>
updateExistingUser(primaryUser.id, { displayName: event.target.value })
}
/>
</label>
<label className="field">
<span>Firmenname</span>
<input
value={primaryUser.companyName ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { companyName: event.target.value })
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={primaryUser.email ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { email: event.target.value })}
/>
</label>
<label className="field">
<span>Strasse</span>
<input
value={primaryUser.street ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { street: event.target.value })}
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
value={primaryUser.houseNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { houseNumber: event.target.value })
}
/>
</label>
<label className="field">
<span>PLZ</span>
<input
value={primaryUser.postalCode ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { postalCode: event.target.value })
}
/>
</label>
<label className="field">
<span>Ort</span>
<input
value={primaryUser.city ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { city: event.target.value })}
/>
</label>
<label className="field">
<span>Telefonnummer</span>
<input
value={primaryUser.phoneNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { phoneNumber: event.target.value })
}
/>
</label>
<label className="field field--wide">
<span>Neues Passwort</span>
<input
type="password"
value={primaryUser.password}
onChange={(event) => updateExistingUser(primaryUser.id, { password: event.target.value })}
/>
</label>
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void saveUser(primaryUser)}>
Stammdaten speichern
</button> </button>
</div> </div>
</section> </section>
) : null} )}
{/* Tabelle mit Benutzern */}
<section className="section-card"> <section className="section-card">
<div className="section-card__header"> <div className="section-card__header">
<div> <div>
<p className="eyebrow">Benutzer</p> <p className="eyebrow">{isAdmin ? "Hauptnutzer" : "Unterbenutzer"}</p>
<h3>Benutzer anlegen</h3> <h3>{isAdmin ? "Registrierte Hauptnutzer" : "Ihre Unterbenutzer"}</h3>
</div> </div>
</div> </div>
<form {loading ? (
className={`field-grid ${showValidation ? "show-validation" : ""}`} <div className="empty-state">Benutzer werden geladen...</div>
onSubmit={createUser} ) : users.length === 0 ? (
autoComplete="off" <div className="empty-state">
> {isAdmin ? "Keine Hauptnutzer vorhanden." : "Keine Unterbenutzer vorhanden."}
<label className="field field--required">
<span>Name</span>
<input
required
value={newUser.displayName}
onChange={(event) =>
setNewUser((current) => ({ ...current, displayName: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>E-Mail</span>
<input
required
type="email"
value={newUser.email ?? ""}
autoComplete="off"
onChange={(event) => setNewUser((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<label className="field field--required">
<span>Passwort</span>
<input
required
type="password"
autoComplete="new-password"
value={newUser.password}
onChange={(event) =>
setNewUser((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>Passwort wiederholen</span>
<input
required
type="password"
autoComplete="new-password"
className={
showValidation && newUser.password !== newUser.passwordRepeat ? "is-invalid" : ""
}
value={newUser.passwordRepeat}
onChange={(event) =>
setNewUser((current) => ({ ...current, passwordRepeat: event.target.value }))
}
/>
</label>
{isAdmin ? (
<label className="field">
<span>Rolle</span>
<select
value={newUser.role}
onChange={(event) =>
setNewUser((current) => ({
...current,
role: event.target.value as UserRole,
}))
}
>
<option value="CUSTOMER">CUSTOMER</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
) : null}
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div> </div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzerliste</p>
<h3>Bereits angelegte Benutzer</h3>
</div>
</div>
{secondaryUsers.length === 0 ? (
<div className="empty-state">Noch keine weiteren Benutzer vorhanden.</div>
) : ( ) : (
<div className="table-shell"> <div className="table-shell">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th className="required-label">Name</th> <th>Name</th>
{isAdmin && <th>Firma</th>}
<th>E-Mail</th> <th>E-Mail</th>
<th>Passwort</th> <th>Status</th>
<th>Aktiv</th> <th>Letzte Änderung</th>
<th /> <th>Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{secondaryUsers.map((entry) => ( {users.map((entry) => (
<tr key={entry.id}> <tr key={entry.id} className={!entry.active ? "table-row--inactive" : ""}>
<td> <td>
<input <strong>{entry.displayName}</strong>
required
className={showValidation && !entry.displayName.trim() ? "is-invalid" : ""}
value={entry.displayName}
onChange={(event) =>
updateExistingUser(entry.id, { displayName: event.target.value })
}
/>
</td> </td>
{isAdmin && <td>{(entry as SubUserRow & { companyName?: string }).companyName ?? "-"}</td>}
<td>{entry.email ?? "-"}</td>
<td> <td>
<input <span
type="email" className={`status-pill ${
value={entry.email ?? ""} entry.active ? "status-pill--active" : "status-pill--inactive"
onChange={(event) => updateExistingUser(entry.id, { email: event.target.value })} }`}
/>
</td>
<td>
<input
type="password"
value={entry.password}
onChange={(event) =>
updateExistingUser(entry.id, { password: event.target.value })
}
placeholder="Neues Passwort"
/>
</td>
<td>
<select
value={entry.active ? "true" : "false"}
onChange={(event) =>
updateExistingUser(entry.id, { active: event.target.value === "true" })
}
> >
<option value="true">aktiv</option> {entry.active ? "Freigegeben" : "Gesperrt"}
<option value="false">inaktiv</option> </span>
</select>
</td> </td>
<td className="table-actions"> <td className="text-muted">{formatDate(entry.updatedAt)}</td>
<td>
<button <button
type="button" type="button"
className="table-link" className={`action-button ${
onClick={() => void saveUser(entry)} entry.active ? "action-button--danger" : "action-button--success"
}`}
onClick={() => toggleUserStatus(entry.id, !entry.active)}
> >
Speichern {entry.active ? "Sperren" : "Freigeben"}
</button>
<button
type="button"
className="table-link table-link--danger"
onClick={() => void removeUser(entry.id)}
>
Loeschen
</button> </button>
</td> </td>
</tr> </tr>
@@ -473,7 +379,208 @@ export default function UserManagementPage() {
</table> </table>
</div> </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> </section>
{isPrimaryUser && !isAdmin && showCreateForm ? (
<div className="dialog-backdrop" onClick={closeCreateUserDialog}>
<form className="dialog" onClick={(event) => event.stopPropagation()} onSubmit={handleCreateUser}>
<div className="dialog__header">
<div>
<p className="eyebrow">Neuer Unterbenutzer</p>
<h4>Benutzer anlegen</h4>
</div>
</div>
<div className="dialog__body dialog__body--form">
<div className="field-grid field-grid--2col">
<label className="field">
<span>Name *</span>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="Name des Benutzers"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>E-Mail *</span>
<input
type="email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
placeholder="email@beispiel.de"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>Passwort *</span>
<input
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Passwort"
autoComplete="new-password"
required
/>
</label>
<label className="field">
<span>Passwort wiederholen *</span>
<input
type="password"
value={newUserPasswordConfirm}
onChange={(e) => setNewUserPasswordConfirm(e.target.value)}
placeholder="Passwort wiederholen"
autoComplete="new-password"
required
/>
</label>
</div>
</div>
<div className="dialog__actions dialog__actions--start">
<button
type="button"
className="secondary-button"
onClick={closeCreateUserDialog}
disabled={creating}
>
Abbrechen
</button>
<button
type="submit"
className="accent-button"
disabled={creating}
>
{creating ? "Wird erstellt..." : "Benutzer erstellen"}
</button>
</div>
</form>
</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> </div>
); );
} }

View File

@@ -102,6 +102,13 @@ a {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.sidebar__version {
font-size: 0.65em;
font-weight: 400;
opacity: 0.7;
margin-left: 6px;
}
.sidebar__nav { .sidebar__nav {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -563,6 +570,67 @@ a {
overflow: auto; overflow: auto;
} }
.admin-catalog-list,
.admin-farmer-list {
display: grid;
gap: 20px;
}
.admin-catalog-section,
.admin-farmer-section {
display: grid;
gap: 16px;
}
.admin-farmer-card {
display: grid;
gap: 16px;
padding: 24px;
border: 1px solid rgba(37, 49, 58, 0.08);
border-radius: 24px;
background: rgba(255, 255, 255, 0.42);
}
.admin-catalog-form {
display: grid;
gap: 16px;
padding: 24px;
border: 1px solid rgba(37, 49, 58, 0.08);
border-radius: 24px;
background: rgba(255, 255, 255, 0.42);
}
.admin-farmer-card__row {
display: grid;
gap: 16px;
}
.admin-farmer-card__row--primary {
grid-template-columns: minmax(0, 1.8fr) minmax(0, 1.3fr) minmax(0, 1fr);
}
.admin-farmer-card__row--address {
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.05fr) minmax(0, 1.8fr) minmax(0, 0.9fr);
}
.admin-farmer-card__row--contact {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.1fr);
}
.admin-farmer-card__row--toggle {
grid-template-columns: minmax(0, 180px);
}
.admin-farmer-card__toggle {
min-width: 140px;
justify-content: center;
}
.admin-catalog-form__toggle {
min-width: 140px;
justify-content: center;
}
.data-table { .data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -583,6 +651,62 @@ a {
text-transform: uppercase; text-transform: uppercase;
} }
.admin-farmer-table__row {
cursor: pointer;
}
.admin-catalog-table__row {
cursor: pointer;
}
.admin-farmer-table__row:hover,
.admin-farmer-table__row:focus-visible,
.admin-catalog-table__row:hover,
.admin-catalog-table__row:focus-visible {
background: rgba(17, 109, 99, 0.08);
outline: none;
}
.admin-farmer-table__empty {
color: var(--muted);
text-align: center !important;
}
.admin-catalog-table__empty {
color: var(--muted);
text-align: center !important;
}
@media (max-width: 1200px) {
.admin-farmer-card__row--primary,
.admin-farmer-card__row--address,
.admin-farmer-card__row--contact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-farmer-card__row--toggle {
grid-template-columns: minmax(0, 180px);
}
.admin-farmer-card__toggle {
width: 100%;
}
}
@media (max-width: 720px) {
.admin-catalog-form,
.admin-farmer-card {
padding: 18px;
}
.admin-farmer-card__row--primary,
.admin-farmer-card__row--address,
.admin-farmer-card__row--contact,
.admin-farmer-card__row--toggle {
grid-template-columns: 1fr;
}
}
.status-pill, .status-pill,
.info-chip { .info-chip {
display: inline-flex; display: inline-flex;
@@ -624,10 +748,17 @@ a {
} }
.matrix-button { .matrix-button {
width: 42px; min-width: 90px;
width: auto;
height: 42px; height: 42px;
padding: 0 16px;
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.matrix-col {
text-align: center !important;
} }
.eye-button { .eye-button {
@@ -767,6 +898,14 @@ a {
justify-content: flex-end; justify-content: flex-end;
} }
.user-management-page__create-action {
margin-top: 18px;
}
.user-management-page__profile-header {
margin-bottom: 0;
}
.invoice-template-page { .invoice-template-page {
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@@ -860,7 +999,7 @@ a {
.invoice-template__palette-grid { .invoice-template__palette-grid {
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: 1fr;
} }
.invoice-template__tile { .invoice-template__tile {
@@ -964,7 +1103,7 @@ a {
position: absolute; position: absolute;
display: grid; display: grid;
gap: 0; gap: 0;
padding: 4px 5px; padding: 4px 11px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 16px; border-radius: 16px;
background: transparent; background: transparent;
@@ -1007,10 +1146,71 @@ a {
word-break: break-word; word-break: break-word;
} }
.invoice-template__canvas-line { .invoice-template__muh-items {
display: block; display: grid;
width: 100%; width: 100%;
height: 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; border-radius: 999px;
background: rgba(37, 49, 58, 0.82); background: rgba(37, 49, 58, 0.82);
pointer-events: none; pointer-events: none;
@@ -1088,6 +1288,249 @@ a {
min-height: 88px; min-height: 88px;
} }
/* Admin Dashboard Styles */
.admin-hero {
background: linear-gradient(135deg, rgba(90, 123, 168, 0.15) 0%, rgba(74, 124, 89, 0.1) 100%);
}
.admin-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1024px) {
.admin-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.admin-metrics {
grid-template-columns: 1fr;
}
}
.metric-card--primary {
background: linear-gradient(135deg, rgba(90, 123, 168, 0.25) 0%, rgba(90, 123, 168, 0.1) 100%);
border-color: rgba(90, 123, 168, 0.3);
}
.metric-card__value--large {
font-size: 2.5rem;
background: linear-gradient(135deg, var(--text) 0%, #5b7ba8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.admin-modules-section {
margin-top: 8px;
}
.admin-modules-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 20px;
}
@media (max-width: 1024px) {
.admin-modules-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.admin-modules-grid {
grid-template-columns: 1fr;
}
}
.admin-module-card {
display: flex;
align-items: center;
gap: 16px;
padding: 24px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
border-left: 4px solid var(--module-color, #5b7ba8);
}
.admin-module-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.admin-module-card__icon {
font-size: 2rem;
line-height: 1;
}
.admin-module-card__content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.admin-module-card__content strong {
font-size: 1.1rem;
color: var(--text);
}
.admin-module-card__content .muted-text {
font-size: 0.85rem;
}
.admin-module-card__arrow {
font-size: 1.5rem;
color: var(--muted);
opacity: 0.5;
transition: opacity 0.2s ease;
}
.admin-module-card:hover .admin-module-card__arrow {
opacity: 1;
}
.quick-actions-section {
margin-top: 8px;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 20px;
}
@media (max-width: 1024px) {
.quick-actions-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.quick-actions-grid {
grid-template-columns: 1fr;
}
}
.quick-action-button {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.95rem;
color: var(--text);
}
.quick-action-button:hover {
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow);
}
.quick-action-button span:first-child {
font-size: 1.25rem;
}
/* User Management Table Styles */
.table-row--inactive {
background-color: rgba(157, 60, 48, 0.05);
}
.status-pill--active {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-pill--inactive {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.action-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button--success {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
}
.action-button--success:hover {
background-color: rgba(74, 124, 89, 0.25);
}
.action-button--danger {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
}
.action-button--danger:hover {
background-color: rgba(157, 60, 48, 0.25);
}
.text-muted {
color: var(--muted);
}
.metric-card--secondary {
background: linear-gradient(135deg, rgba(139, 90, 124, 0.2) 0%, rgba(139, 90, 124, 0.05) 100%);
border-color: rgba(139, 90, 124, 0.25);
}
.sample-count {
display: inline-block;
min-width: 32px;
padding: 4px 12px;
background: rgba(90, 123, 168, 0.15);
border-radius: 16px;
font-weight: 600;
color: var(--text);
text-align: center;
}
/* Chart Container */
.chart-container {
position: relative;
height: 400px;
padding: 20px;
background: rgba(255, 255, 255, 0.5);
border-radius: var(--radius-xl);
}
@media (max-width: 768px) {
.chart-container {
height: 300px;
padding: 10px;
}
}
.dialog-backdrop { .dialog-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1133,6 +1576,10 @@ a {
justify-content: flex-end; justify-content: flex-end;
} }
.dialog__actions--start {
justify-content: flex-start;
}
.dialog__actions a { .dialog__actions a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1144,6 +1591,11 @@ a {
min-height: 0; min-height: 0;
} }
.dialog__body--form,
.dialog__body--farmer {
overflow: auto;
}
.dialog__body--pdf { .dialog__body--pdf {
height: min(80vh, 900px); height: min(80vh, 900px);
} }
@@ -1246,3 +1698,84 @@ a {
height: 72vh; 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 { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; 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({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(resolveAppVersion()),
},
server: { server: {
port: 5173, port: 5173,
host: "0.0.0.0", 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 { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; 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({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(resolveAppVersion()),
},
server: { server: {
port: 5173, port: 5173,
host: "0.0.0.0", host: "0.0.0.0",