Compare commits
27 Commits
eb0f921464
...
58c78bbbbd
| Author | SHA1 | Date | |
|---|---|---|---|
| 58c78bbbbd | |||
| dc35995e64 | |||
| 3d9b807261 | |||
| f1d60e2109 | |||
| f7226604e2 | |||
| 4c1dd72659 | |||
| 532dcb83c1 | |||
| e43e9c40ad | |||
| 60e2f95637 | |||
| 8adc817428 | |||
| 5fb6f3303b | |||
| 6dbf5a00c4 | |||
| 775b09ebeb | |||
| 49b1a3b363 | |||
| 571019d34b | |||
| 3c0335ae7c | |||
| fdac954cea | |||
| e315160975 | |||
| 91f67f7dfc | |||
| d03dc94ad1 | |||
| 7c59944646 | |||
| 3367129d37 | |||
| 217e0b8dc0 | |||
| dbc8c2a2a2 | |||
| 93f52f1ae1 | |||
| b9919828e4 | |||
| ce76a29038 |
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.svencarstensen.muh.domain;
|
||||
|
||||
public record Pretreatment(
|
||||
String inUdderInjector,
|
||||
String systemicAntibiotics,
|
||||
String painMedication,
|
||||
String dryOffTreatment
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
14
frontend/src/lib/version.ts
Normal file
14
frontend/src/lib/version.ts
Normal 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];
|
||||
@@ -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`;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
365
frontend/src/pages/AdminProfilePage.tsx
Normal file
365
frontend/src/pages/AdminProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ fontWeight: 500 }}>Gesamtrechnungen</td>
|
||||
<td style={{ textAlign: "right" }}>{invoices.length}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ fontWeight: 500 }}>Bezahlt</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{invoices.filter((i) => i.status === "PAID").length}
|
||||
</span>
|
||||
<span className="stat-card__label">Bezahlt</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-card__value">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ fontWeight: 500 }}>Überfällig</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{invoices.filter((i) => i.status === "OVERDUE").length}
|
||||
</span>
|
||||
<span className="stat-card__label">Überfällig</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-card__value">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ fontWeight: 500 }}>Gesamtumsatz</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{formatCurrency(
|
||||
invoices
|
||||
.filter((i) => i.status === "PAID")
|
||||
.reduce((sum, i) => sum + i.totalAmount, 0)
|
||||
)}
|
||||
</span>
|
||||
<span className="stat-card__label">Gesamtumsatz</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
160
frontend/src/pages/PricingPage.tsx
Normal file
160
frontend/src/pages/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1293,7 +1293,6 @@ export default function ReportTemplatePage() {
|
||||
setResizingElementId(null);
|
||||
setTemplateUpdatedAt(null);
|
||||
setTemplateError(null);
|
||||
setIsTemplateApiAvailable(false);
|
||||
return;
|
||||
}
|
||||
setTemplateError((error as Error).message);
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -11,6 +11,12 @@ function medicationOptions(catalogs: ActiveCatalogSummary, category: MedicationC
|
||||
return catalogs.medications.filter((medication) => medication.category === category);
|
||||
}
|
||||
|
||||
// Options for dropdowns like in Lua version
|
||||
const COUNT_OPTIONS = ["1", "2", "3", "4", "5"];
|
||||
const DURATION_OPTIONS = ["1 Tag", "2 Tage", "3 Tage", "4 Tage", "5 Tage", "6 Tage", "7 Tage", "8 Tage", "10 Tage", "14 Tage"];
|
||||
const DOSAGE_OPTIONS = ["einmalig", "1 x", "2 x", "3 x"];
|
||||
const LOCATION_OPTIONS = ["i.m.", "i.v.", "s.c."];
|
||||
|
||||
export default function TherapyPage() {
|
||||
const { sampleId } = useParams();
|
||||
|
||||
@@ -26,6 +32,15 @@ export default function TherapyPage() {
|
||||
const [dryAntibioticKeys, setDryAntibioticKeys] = useState<string[]>([]);
|
||||
const [farmerNote, setFarmerNote] = useState("");
|
||||
const [internalNote, setInternalNote] = useState("");
|
||||
// New fields from Lua version
|
||||
const [inUdderCount, setInUdderCount] = useState("");
|
||||
const [inUdderDuration, setInUdderDuration] = useState("");
|
||||
const [systemicCount, setSystemicCount] = useState("");
|
||||
const [systemicDuration, setSystemicDuration] = useState("");
|
||||
const [systemicDosage, setSystemicDosage] = useState("");
|
||||
const [systemicLocation, setSystemicLocation] = useState("");
|
||||
const [startvacVaccination, setStartvacVaccination] = useState(false);
|
||||
const [noAntibioticTreatment, setNoAntibioticTreatment] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -51,6 +66,15 @@ export default function TherapyPage() {
|
||||
setDryAntibioticKeys(sampleResponse.therapy?.dryAntibioticKeys ?? []);
|
||||
setFarmerNote(sampleResponse.therapy?.farmerNote ?? "");
|
||||
setInternalNote(sampleResponse.therapy?.internalNote ?? "");
|
||||
// Load new fields
|
||||
setInUdderCount(sampleResponse.therapy?.inUdderCount ?? "");
|
||||
setInUdderDuration(sampleResponse.therapy?.inUdderDuration ?? "");
|
||||
setSystemicCount(sampleResponse.therapy?.systemicCount ?? "");
|
||||
setSystemicDuration(sampleResponse.therapy?.systemicDuration ?? "");
|
||||
setSystemicDosage(sampleResponse.therapy?.systemicDosage ?? "");
|
||||
setSystemicLocation(sampleResponse.therapy?.systemicLocation ?? "");
|
||||
setStartvacVaccination(sampleResponse.therapy?.startvacVaccination ?? false);
|
||||
setNoAntibioticTreatment(sampleResponse.therapy?.noAntibioticTreatment ?? false);
|
||||
} catch (loadError) {
|
||||
setMessage((loadError as Error).message);
|
||||
}
|
||||
@@ -65,6 +89,14 @@ export default function TherapyPage() {
|
||||
setter(list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]);
|
||||
}
|
||||
|
||||
// Check if "Keine" (None) is selected for in-udder or systemic
|
||||
const noInUdderSelected = inUdderMedicationKeys.some(key =>
|
||||
catalogs?.medications.find(m => m.businessKey === key)?.name === "Keine"
|
||||
);
|
||||
const noSystemicSelected = systemicMedicationKeys.some(key =>
|
||||
catalogs?.medications.find(m => m.businessKey === key)?.name === "Keine"
|
||||
);
|
||||
|
||||
async function handleSave() {
|
||||
if (!sampleId) {
|
||||
return;
|
||||
@@ -83,6 +115,15 @@ export default function TherapyPage() {
|
||||
dryAntibioticKeys,
|
||||
farmerNote,
|
||||
internalNote,
|
||||
// New fields
|
||||
inUdderCount: inUdderCount || null,
|
||||
inUdderDuration: inUdderDuration || null,
|
||||
systemicCount: systemicCount || null,
|
||||
systemicDuration: systemicDuration || null,
|
||||
systemicDosage: systemicDosage || null,
|
||||
systemicLocation: systemicLocation || null,
|
||||
startvacVaccination,
|
||||
noAntibioticTreatment,
|
||||
});
|
||||
setSample(response);
|
||||
setMessage(response.completed ? "Probe gespeichert und abgeschlossen." : "Aenderung gespeichert.");
|
||||
@@ -168,7 +209,35 @@ export default function TherapyPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* In-Udder Details from Lua */}
|
||||
{!noInUdderSelected && inUdderMedicationKeys.length > 0 && (
|
||||
<div className="field-grid field-grid--2col section-card__spacer">
|
||||
<label className="field">
|
||||
<span>Anzahl</span>
|
||||
<select
|
||||
value={inUdderCount}
|
||||
onChange={(e) => setInUdderCount(e.target.value)}
|
||||
disabled={therapyLocked}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{COUNT_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Dauer</span>
|
||||
<select
|
||||
value={inUdderDuration}
|
||||
onChange={(e) => setInUdderDuration(e.target.value)}
|
||||
disabled={therapyLocked}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{DURATION_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="field section-card__spacer">
|
||||
<span>Sonstiges</span>
|
||||
<textarea
|
||||
value={inUdderOther}
|
||||
@@ -202,7 +271,57 @@ export default function TherapyPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Systemic Details from Lua */}
|
||||
{!noSystemicSelected && systemicMedicationKeys.length > 0 && (
|
||||
<div className="field-grid field-grid--2col section-card__spacer">
|
||||
<label className="field">
|
||||
<span>Anzahl</span>
|
||||
<select
|
||||
value={systemicCount}
|
||||
onChange={(e) => setSystemicCount(e.target.value)}
|
||||
disabled={therapyLocked}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{COUNT_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Dauer</span>
|
||||
<select
|
||||
value={systemicDuration}
|
||||
onChange={(e) => setSystemicDuration(e.target.value)}
|
||||
disabled={therapyLocked}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{DURATION_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Dosierung</span>
|
||||
<select
|
||||
value={systemicDosage}
|
||||
onChange={(e) => setSystemicDosage(e.target.value)}
|
||||
disabled={therapyLocked}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{DOSAGE_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Ort</span>
|
||||
<select
|
||||
value={systemicLocation}
|
||||
onChange={(e) => setSystemicLocation(e.target.value)}
|
||||
disabled={therapyLocked}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{LOCATION_OPTIONS.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="field section-card__spacer">
|
||||
<span>Sonstiges</span>
|
||||
<textarea
|
||||
value={systemicOther}
|
||||
@@ -254,6 +373,33 @@ export default function TherapyPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Additional Options from Lua */}
|
||||
{sample.sampleKind === "LACTATION" && (
|
||||
<section className="section-card">
|
||||
<p className="eyebrow">Sonstiges</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noAntibioticTreatment}
|
||||
onChange={(e) => setNoAntibioticTreatment(e.target.checked)}
|
||||
disabled={therapyLocked}
|
||||
/>
|
||||
<span>Keine Antibiose, gut ausmelken (evtl. Oxytocin), als letzte melken, strikte Hygiene</span>
|
||||
</label>
|
||||
<label className="field field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={startvacVaccination}
|
||||
onChange={(e) => setStartvacVaccination(e.target.checked)}
|
||||
disabled={therapyLocked}
|
||||
/>
|
||||
<span>Startvac-Impfung</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="form-grid">
|
||||
<article className="section-card">
|
||||
<label className="field">
|
||||
|
||||
@@ -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
|
||||
if (isAdmin) {
|
||||
// Admin sieht alle Hauptnutzer (außer andere Admins)
|
||||
const primaryUsers = response
|
||||
.filter((u) => u.primaryUser && u.role !== "ADMIN")
|
||||
.map((u) => ({
|
||||
id: u.id,
|
||||
displayName: u.displayName,
|
||||
companyName: u.companyName,
|
||||
email: u.email,
|
||||
active: u.active,
|
||||
updatedAt: u.updatedAt,
|
||||
}));
|
||||
setUsers(primaryUsers);
|
||||
} else {
|
||||
// Hauptnutzer sieht alle seine Unterbenutzer (nicht-primary)
|
||||
const subUsers = response
|
||||
.filter((u) => !u.primaryUser)
|
||||
.map((u) => ({
|
||||
id: u.id,
|
||||
displayName: u.displayName,
|
||||
email: u.email,
|
||||
active: u.active,
|
||||
updatedAt: u.updatedAt,
|
||||
}));
|
||||
setUsers(subUsers);
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage((error as Error).message);
|
||||
} finally {
|
||||
@@ -43,7 +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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user