Compare commits

...

27 Commits

Author SHA1 Message Date
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
32 changed files with 2602 additions and 341 deletions

View File

@@ -105,3 +105,8 @@ Kundenregistrierung:
- `cd backend && mvn test`
- `cd frontend && npm run build`
## Docker
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

@@ -19,10 +19,20 @@ public record AppUser(
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String passwordHash,
boolean active,
UserRole role,
Long nextSampleNumber,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public AppUser {
if (nextSampleNumber == null) {
nextSampleNumber = 100000L;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -436,9 +436,14 @@ public class CatalogService {
adminManaged ? blankToNull(mutation.city()) : null,
normalizeEmail(mutation.email()),
adminManaged ? blankToNull(mutation.phoneNumber()) : null,
adminManaged ? blankToNull(mutation.accountHolder()) : null,
adminManaged ? blankToNull(mutation.bankName()) : null,
adminManaged ? blankToNull(mutation.iban()) : null,
adminManaged ? blankToNull(mutation.bic()) : null,
encodeIfPresent(mutation.password()),
mutation.active(),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
100000L,
now,
now
));
@@ -466,11 +471,16 @@ public class CatalogService {
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.city()) : existing.city(),
normalizeEmail(mutation.email()),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.phoneNumber()) : existing.phoneNumber(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.accountHolder()) : existing.accountHolder(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.bankName()) : existing.bankName(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.iban()) : existing.iban(),
actor.role() == UserRole.ADMIN ? blankToNull(mutation.bic()) : existing.bic(),
isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(),
actor.role() == UserRole.ADMIN
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()),
existing.nextSampleNumber(),
existing.createdAt(),
now
));
@@ -514,9 +524,14 @@ public class CatalogService {
existing.city(),
existing.email(),
existing.phoneNumber(),
existing.accountHolder(),
existing.bankName(),
existing.iban(),
existing.bic(),
passwordEncoder.encode(newPassword),
existing.active(),
existing.role(),
existing.nextSampleNumber(),
existing.createdAt(),
LocalDateTime.now()
));
@@ -534,7 +549,7 @@ public class CatalogService {
return toSessionResponse(user);
}
public SessionResponse registerCustomer(RegistrationMutation mutation) {
public RegistrationResponse registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName())
|| isBlank(mutation.street())
|| isBlank(mutation.houseNumber())
@@ -577,9 +592,14 @@ public class CatalogService {
city,
normalizedEmail,
phoneNumber,
null,
null,
null,
null,
passwordEncoder.encode(mutation.password()),
true,
false,
UserRole.CUSTOMER,
100000L,
now,
now
));
@@ -596,13 +616,21 @@ public class CatalogService {
created.city(),
created.email(),
created.phoneNumber(),
created.accountHolder(),
created.bankName(),
created.iban(),
created.bic(),
created.passwordHash(),
created.active(),
false,
created.role(),
created.nextSampleNumber(),
created.createdAt(),
created.updatedAt()
));
return toSessionResponse(accountBound);
return new RegistrationResponse(accountBound.id(), accountBound.email());
}
public record RegistrationResponse(String userId, String email) {
}
public UserOption currentUser(String actorId) {
@@ -700,6 +728,10 @@ public class CatalogService {
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.active(),
normalizeStoredRole(user.role()),
user.updatedAt()
@@ -735,6 +767,10 @@ public class CatalogService {
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
normalizeStoredRole(user.role())
);
}
@@ -827,9 +863,14 @@ public class CatalogService {
user.city(),
user.email(),
user.phoneNumber(),
user.accountHolder(),
user.bankName(),
user.iban(),
user.bic(),
user.passwordHash(),
user.active(),
normalizeStoredRole(user.role()),
user.nextSampleNumber(),
user.createdAt(),
now
)));
@@ -861,9 +902,14 @@ public class CatalogService {
null,
email,
null,
null,
null,
null,
null,
passwordEncoder.encode(rawPassword),
true,
role,
100000L,
now,
now
));
@@ -987,6 +1033,10 @@ public class CatalogService {
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
UserRole role
) {
}
@@ -1056,6 +1106,10 @@ public class CatalogService {
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
boolean active,
UserRole role,
LocalDateTime updatedAt
@@ -1073,6 +1127,10 @@ public class CatalogService {
String city,
String email,
String phoneNumber,
String accountHolder,
String bankName,
String iban,
String bic,
String password,
boolean active,
UserRole role

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSession } from "../lib/session";
import { APP_VERSION } from "../lib/version";
const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite",
@@ -7,6 +8,8 @@ const PAGE_TITLES: Record<string, string> = {
"/samples/new": "Neuanlage einer Probe",
"/portal": "MUH-Portal",
"/report-template": "Bericht",
"/admin/stammdaten": "Meine Stammdaten",
"/admin/preistabelle": "Preistabelle",
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
"/admin/rechnung/template": "Rechnungsvorlage",
};
@@ -39,7 +42,9 @@ export default function AppShell() {
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar__brand">
<div className="sidebar__logo">MUH</div>
<div className="sidebar__logo">
MUH <span className="sidebar__version">({APP_VERSION})</span>
</div>
</div>
<nav className="sidebar__nav">
@@ -61,6 +66,20 @@ export default function AppShell() {
</div>
</div>
<NavLink
to="/admin/stammdaten"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Stammdaten
</NavLink>
<NavLink
to="/admin/preistabelle"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Preistabelle
</NavLink>
<div className="nav-group">
<div className="nav-group__label">Rechnung</div>
<div className="nav-subnav">

View File

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

View File

@@ -9,6 +9,13 @@ export type QuarterKey =
| "LEFT_REAR"
| "RIGHT_REAR";
export type PathogenKind = "BACTERIAL" | "NO_GROWTH" | "CONTAMINATED" | "OTHER";
export interface Pretreatment {
inUdderInjector: string | null;
systemicAntibiotics: string | null;
painMedication: string | null;
dryOffTreatment: string | null;
}
export type SensitivityResult = "SENSITIVE" | "INTERMEDIATE" | "RESISTANT";
export type MedicationCategory =
| "IN_UDDER"
@@ -55,6 +62,10 @@ export interface UserOption {
city: string | null;
email: string | null;
phoneNumber: string | null;
accountHolder: string | null;
bankName: string | null;
iban: string | null;
bic: string | null;
role: UserRole;
}
@@ -145,6 +156,14 @@ export interface TherapyView {
dryAntibioticNames: string[];
farmerNote: string | null;
internalNote: string | null;
inUdderCount: string | null;
inUdderDuration: string | null;
systemicCount: string | null;
systemicDuration: string | null;
systemicDosage: string | null;
systemicLocation: string | null;
startvacVaccination: boolean | null;
noAntibioticTreatment: boolean | null;
}
export interface SampleDetail {
@@ -176,6 +195,9 @@ export interface SampleDetail {
antibiogramEditable: boolean;
therapyEditable: boolean;
completed: boolean;
pretreatment: Pretreatment | null;
clinicalExamDate: string | null;
internalNote: string | null;
}
export interface FarmerRow {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -158,33 +158,37 @@ export default function InvoiceManagementPage() {
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-card__value">{invoices.length}</span>
<span className="stat-card__label">Gesamtrechnungen</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "PAID").length}
</span>
<span className="stat-card__label">Bezahlt</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "OVERDUE").length}
</span>
<span className="stat-card__label">Überfällig</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{formatCurrency(
invoices
.filter((i) => i.status === "PAID")
.reduce((sum, i) => sum + i.totalAmount, 0)
)}
</span>
<span className="stat-card__label">Gesamtumsatz</span>
</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>
</div>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,38 +3,90 @@ import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserRow } from "../lib/types";
interface PrimaryUserRow {
interface SubUserRow {
id: string;
displayName: string;
companyName: string | null;
email: string | null;
active: boolean;
updatedAt: string;
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<PrimaryUserRow[]>([]);
const { user, updateUser } = useSession();
const [users, setUsers] = useState<SubUserRow[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const isAdmin = user?.role === "ADMIN";
const isPrimaryUser = user?.primaryUser === true;
const canManageUsers = isAdmin || isPrimaryUser;
// Form state for creating new sub-user
const [newUserName, setNewUserName] = useState("");
const [newUserEmail, setNewUserEmail] = useState("");
const [newUserPassword, setNewUserPassword] = useState("");
const [newUserPasswordConfirm, setNewUserPasswordConfirm] = useState("");
const [creating, setCreating] = useState(false);
// Form state for editing own profile
const [showProfileForm, setShowProfileForm] = useState(false);
const [profileData, setProfileData] = useState({
displayName: "",
companyName: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
});
const [savingProfile, setSavingProfile] = useState(false);
// Initialize profile data from session user
useEffect(() => {
if (user) {
setProfileData({
displayName: user.displayName || "",
companyName: user.companyName || "",
street: user.street || "",
houseNumber: user.houseNumber || "",
postalCode: user.postalCode || "",
city: user.city || "",
email: user.email || "",
phoneNumber: user.phoneNumber || "",
});
}
}, [user]);
useEffect(() => {
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
// Nur Hauptnutzer (primaryUser=true) anzeigen, aber Admin ausblenden
const primaryUsers = response
.filter((u) => u.primaryUser && u.role !== "ADMIN")
.map((u) => ({
id: u.id,
displayName: u.displayName,
companyName: u.companyName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(primaryUsers);
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 {
@@ -43,7 +95,7 @@ export default function UserManagementPage() {
}
void loadUsers();
}, []);
}, [isAdmin]);
async function toggleUserStatus(userId: string, newStatus: boolean) {
try {
@@ -65,14 +117,105 @@ export default function UserManagementPage() {
}.`
);
// Nachricht nach 3 Sekunden ausblenden
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
}
}
// Formatierungsfunktion für das Datum
async function handleCreateUser(e: React.FormEvent) {
e.preventDefault();
if (!newUserName.trim() || !newUserEmail.trim() || !newUserPassword.trim()) {
setMessage("Bitte alle Felder ausfüllen.");
return;
}
if (newUserPassword !== newUserPasswordConfirm) {
setMessage("Die Passwörter stimmen nicht überein.");
return;
}
setCreating(true);
try {
await apiPost("/portal/users", {
displayName: newUserName.trim(),
email: newUserEmail.trim(),
password: newUserPassword,
});
// Reload users
const response = await apiGet<UserRow[]>("/portal/users");
const subUsers = response
.filter((u) => !u.primaryUser)
.map((u) => ({
id: u.id,
displayName: u.displayName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(subUsers);
// Reset form
setNewUserName("");
setNewUserEmail("");
setNewUserPassword("");
setNewUserPasswordConfirm("");
setShowCreateForm(false);
setMessage(`Benutzer "${newUserName}" wurde erstellt.`);
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
} finally {
setCreating(false);
}
}
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
if (!profileData.displayName.trim()) {
setMessage("Name ist erforderlich.");
return;
}
setSavingProfile(true);
try {
const response = await apiPost<UserRow>("/portal/users", {
id: user?.id,
displayName: profileData.displayName.trim(),
companyName: profileData.companyName.trim() || null,
street: profileData.street.trim() || null,
houseNumber: profileData.houseNumber.trim() || null,
postalCode: profileData.postalCode.trim() || null,
city: profileData.city.trim() || null,
email: profileData.email.trim() || null,
phoneNumber: profileData.phoneNumber.trim() || null,
});
// Update session user
if (updateUser && user) {
updateUser({
...user,
displayName: response.displayName,
companyName: response.companyName,
street: response.street,
houseNumber: response.houseNumber,
postalCode: response.postalCode,
city: response.city,
email: response.email,
phoneNumber: response.phoneNumber,
});
}
setShowProfileForm(false);
setMessage("Ihre Stammdaten wurden aktualisiert.");
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
} finally {
setSavingProfile(false);
}
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
@@ -80,13 +223,12 @@ export default function UserManagementPage() {
}).format(new Date(value));
}
// Nicht-Admin Ansicht (sollte nicht passieren, da Route geschützt ist)
if (!isAdmin) {
if (!canManageUsers) {
return (
<div className="page-stack">
<section className="section-card">
<div className="alert alert--error">
Zugriff verweigert. Diese Seite ist nur für Administratoren.
Zugriff verweigert. Diese Seite ist nur für Administratoren und Hauptbenutzer.
</div>
</section>
</div>
@@ -99,10 +241,11 @@ export default function UserManagementPage() {
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Benutzerverwaltung</p>
<h3>Hauptnutzer freigeben oder sperren</h3>
<h3>{isAdmin ? "Hauptnutzer freigeben oder sperren" : "Unterbenutzer verwalten"}</h3>
<p className="muted-text">
Verwalten Sie den Zugriff von Hauptnutzern auf das System.
Gesperrte Benutzer können sich nicht mehr anmelden.
{isAdmin
? "Verwalten Sie den Zugriff von Hauptnutzern auf das System. Gesperrte Benutzer können sich nicht mehr anmelden."
: "Erstellen und verwalten Sie Unterbenutzer für Ihr Konto. Unterbenutzer können Proben registrieren und bearbeiten."}
</p>
</div>
</section>
@@ -111,7 +254,10 @@ export default function UserManagementPage() {
{message ? (
<div
className={
message.includes("freigegeben") || message.includes("gesperrt")
message.includes("freigegeben") ||
message.includes("gesperrt") ||
message.includes("erstellt") ||
message.includes("aktualisiert")
? "alert alert--success"
: "alert alert--error"
}
@@ -120,26 +266,242 @@ export default function UserManagementPage() {
</div>
) : null}
{/* Tabelle mit Hauptnutzern */}
{/* Own Profile Form (nur für Hauptbenutzer) */}
{isPrimaryUser && !isAdmin && (
<section className="section-card">
{!showProfileForm ? (
<div className="section-card__header">
<div>
<p className="eyebrow">Meine Stammdaten</p>
<h3>{user?.displayName}</h3>
</div>
<button
type="button"
className="accent-button"
onClick={() => setShowProfileForm(true)}
>
Bearbeiten
</button>
</div>
) : (
<>
<div className="section-card__header">
<div>
<p className="eyebrow">Meine Stammdaten</p>
<h3>Stammdaten bearbeiten</h3>
</div>
<button
type="button"
className="ghost-button"
onClick={() => setShowProfileForm(false)}
>
Abbrechen
</button>
</div>
<form onSubmit={handleSaveProfile} 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 className="field" style={{ gridColumn: "1 / -1" }}>
<button
type="submit"
className="accent-button"
disabled={savingProfile}
>
{savingProfile ? "Wird gespeichert..." : "Stammdaten speichern"}
</button>
</div>
</form>
</>
)}
</section>
)}
{/* Create Sub-user Form (nur für Hauptnutzer) */}
{isPrimaryUser && !isAdmin && (
<section className="section-card">
{!showCreateForm ? (
<div className="section-card__header">
<div>
<p className="eyebrow">Neuer Unterbenutzer</p>
<h3>Benutzer anlegen</h3>
</div>
<button
type="button"
className="accent-button"
onClick={() => setShowCreateForm(true)}
>
+ Benutzer anlegen
</button>
</div>
) : (
<>
<div className="section-card__header">
<div>
<p className="eyebrow">Neuer Unterbenutzer</p>
<h3>Benutzer anlegen</h3>
</div>
<button
type="button"
className="ghost-button"
onClick={() => setShowCreateForm(false)}
>
Abbrechen
</button>
</div>
<form onSubmit={handleCreateUser} className="field-grid field-grid--2col">
<label className="field">
<span>Name *</span>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="Name des Benutzers"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>E-Mail *</span>
<input
type="email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
placeholder="email@beispiel.de"
autoComplete="off"
required
/>
</label>
<label className="field">
<span>Passwort *</span>
<input
type="password"
value={newUserPassword}
onChange={(e) => setNewUserPassword(e.target.value)}
placeholder="Passwort"
autoComplete="new-password"
required
/>
</label>
<label className="field">
<span>Passwort wiederholen *</span>
<input
type="password"
value={newUserPasswordConfirm}
onChange={(e) => setNewUserPasswordConfirm(e.target.value)}
placeholder="Passwort wiederholen"
autoComplete="new-password"
required
/>
</label>
<div className="field" style={{ display: "flex", alignItems: "flex-end" }}>
<button
type="submit"
className="accent-button"
disabled={creating}
style={{ width: "100%" }}
>
{creating ? "Wird erstellt..." : "Benutzer erstellen"}
</button>
</div>
</form>
</>
)}
</section>
)}
{/* Tabelle mit Benutzern */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Hauptnutzer</p>
<h3>Registrierte Hauptnutzer</h3>
<p className="eyebrow">{isAdmin ? "Hauptnutzer" : "Unterbenutzer"}</p>
<h3>{isAdmin ? "Registrierte Hauptnutzer" : "Ihre Unterbenutzer"}</h3>
</div>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen...</div>
) : users.length === 0 ? (
<div className="empty-state">Keine Hauptnutzer vorhanden.</div>
<div className="empty-state">
{isAdmin ? "Keine Hauptnutzer vorhanden." : "Keine Unterbenutzer vorhanden."}
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Firma</th>
{isAdmin && <th>Firma</th>}
<th>E-Mail</th>
<th>Status</th>
<th>Letzte Änderung</th>
@@ -152,7 +514,7 @@ export default function UserManagementPage() {
<td>
<strong>{entry.displayName}</strong>
</td>
<td>{entry.companyName ?? "-"}</td>
{isAdmin && <td>{(entry as SubUserRow & { companyName?: string }).companyName ?? "-"}</td>}
<td>{entry.email ?? "-"}</td>
<td>
<span
@@ -188,9 +550,9 @@ export default function UserManagementPage() {
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Hauptnutzer sind die primären Kontoinhaber. Wenn Sie einen Hauptnutzer sperren,
können sich dieser und alle zugehörigen Nebennutzer nicht mehr anmelden.
Die Daten bleiben erhalten und können durch Freigabe wieder aktiviert werden.
{isAdmin
? "Hauptnutzer sind die primären Kontoinhaber. Wenn Sie einen Hauptnutzer sperren, können sich dieser und alle zugehörigen Nebennutzer nicht mehr anmelden. Die Daten bleiben erhalten und können durch Freigabe wieder aktiviert werden."
: "Unterbenutzer können Proben registrieren und bearbeiten, aber keine neuen Benutzer anlegen. Wenn Sie einen Unterbenutzer sperren, kann sich dieser nicht mehr anmelden."}
</p>
</div>
</section>

View File

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