feat: Extend React/Java app to match Lua functionality

Backend:
- Add Pretreatment record for pre-treatment data
- Extend Sample with pretreatment, clinicalExamDate, internalNote
- Extend TherapyRecommendation with detail fields (count, duration, dosage, location)
- Add startvacVaccination and noAntibioticTreatment flags
- Add null-safety defaults for MongoDB compatibility

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

Styling:
- Enlarge matrix buttons for S/I/R text
- Add matrix-col class for centered table columns
This commit is contained in:
2026-03-17 16:50:40 +01:00
parent 7c59944646
commit d03dc94ad1
13 changed files with 569 additions and 37 deletions

View File

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

View File

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

View File

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

View File

@@ -537,7 +537,7 @@ public class CatalogService {
return toSessionResponse(user); return toSessionResponse(user);
} }
public SessionResponse registerCustomer(RegistrationMutation mutation) { public RegistrationResponse registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName()) if (isBlank(mutation.companyName())
|| isBlank(mutation.street()) || isBlank(mutation.street())
|| isBlank(mutation.houseNumber()) || isBlank(mutation.houseNumber())

View File

@@ -4,6 +4,7 @@ import de.svencarstensen.muh.domain.AntibiogramEntry;
import de.svencarstensen.muh.domain.AppUser; import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.PathogenCatalogItem; import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind; import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.Pretreatment;
import de.svencarstensen.muh.domain.QuarterAntibiogram; import de.svencarstensen.muh.domain.QuarterAntibiogram;
import de.svencarstensen.muh.domain.QuarterFinding; import de.svencarstensen.muh.domain.QuarterFinding;
import de.svencarstensen.muh.domain.QuarterKey; import de.svencarstensen.muh.domain.QuarterKey;
@@ -22,6 +23,8 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@@ -105,6 +108,18 @@ public class SampleService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
long sampleNumber = reserveNextSampleNumber(actorId); long sampleNumber = reserveNextSampleNumber(actorId);
Pretreatment pretreatment = request.pretreatmentInUdderInjector() == null &&
request.pretreatmentSystemicAntibiotics() == null &&
request.pretreatmentPainMedication() == null &&
request.pretreatmentDryOffTreatment() == null
? null
: new Pretreatment(
blankToNull(request.pretreatmentInUdderInjector()),
blankToNull(request.pretreatmentSystemicAntibiotics()),
blankToNull(request.pretreatmentPainMedication()),
blankToNull(request.pretreatmentDryOffTreatment())
);
Sample sample = new Sample( Sample sample = new Sample(
null, null,
sampleNumber, sampleNumber,
@@ -127,7 +142,10 @@ public class SampleService {
null, null,
authorizationService.accountId(actor), authorizationService.accountId(actor),
request.userCode(), request.userCode(),
request.userDisplayName() request.userDisplayName(),
pretreatment,
parseClinicalExamDate(request.clinicalExamDate()),
blankToNull(request.internalNote())
); );
return toDetail(sampleRepository.save(sample)); return toDetail(sampleRepository.save(sample));
@@ -144,6 +162,18 @@ public class SampleService {
.findFirst() .findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
Pretreatment pretreatment = request.pretreatmentInUdderInjector() == null &&
request.pretreatmentSystemicAntibiotics() == null &&
request.pretreatmentPainMedication() == null &&
request.pretreatmentDryOffTreatment() == null
? existing.pretreatment()
: new Pretreatment(
blankToNull(request.pretreatmentInUdderInjector()),
blankToNull(request.pretreatmentSystemicAntibiotics()),
blankToNull(request.pretreatmentPainMedication()),
blankToNull(request.pretreatmentDryOffTreatment())
);
Sample saved = sampleRepository.save(new Sample( Sample saved = sampleRepository.save(new Sample(
existing.id(), existing.id(),
existing.sampleNumber(), existing.sampleNumber(),
@@ -166,7 +196,14 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
pretreatment,
parseClinicalExamDate(request.clinicalExamDate()) != null
? parseClinicalExamDate(request.clinicalExamDate())
: existing.clinicalExamDate(),
request.internalNote() != null
? blankToNull(request.internalNote())
: existing.internalNote()
)); ));
return toDetail(saved); return toDetail(saved);
@@ -233,7 +270,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
return toDetail(saved); return toDetail(saved);
} }
@@ -324,7 +364,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
return toDetail(saved); return toDetail(saved);
} }
@@ -334,7 +377,7 @@ public class SampleService {
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) { if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
TherapyRecommendation previous = existing.therapyRecommendation(); TherapyRecommendation previous = existing.therapyRecommendation();
TherapyRecommendation updated = previous == null TherapyRecommendation updated = previous == null
? new TherapyRecommendation(false, false, List.of(), List.of(), null, List.of(), List.of(), null, List.of(), List.of(), List.of(), List.of(), null, blankToNull(request.internalNote())) ? new TherapyRecommendation(false, false, List.of(), List.of(), null, List.of(), List.of(), null, List.of(), List.of(), List.of(), List.of(), null, blankToNull(request.internalNote()), null, null, null, null, null, null, Boolean.FALSE, Boolean.FALSE)
: new TherapyRecommendation( : new TherapyRecommendation(
previous.continueStarted(), previous.continueStarted(),
previous.switchTherapy(), previous.switchTherapy(),
@@ -349,7 +392,15 @@ public class SampleService {
previous.dryAntibioticKeys(), previous.dryAntibioticKeys(),
previous.dryAntibioticNames(), previous.dryAntibioticNames(),
previous.farmerNote(), previous.farmerNote(),
blankToNull(request.internalNote()) blankToNull(request.internalNote()),
previous.inUdderCount(),
previous.inUdderDuration(),
previous.systemicCount(),
previous.systemicDuration(),
previous.systemicDosage(),
previous.systemicLocation(),
previous.startvacVaccination() != null ? previous.startvacVaccination() : Boolean.FALSE,
previous.noAntibioticTreatment() != null ? previous.noAntibioticTreatment() : Boolean.FALSE
); );
return toDetail(sampleRepository.save(new Sample( return toDetail(sampleRepository.save(new Sample(
existing.id(), existing.id(),
@@ -373,7 +424,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
))); )));
} }
@@ -396,7 +450,15 @@ public class SampleService {
request.dryAntibioticKeys(), request.dryAntibioticKeys(),
resolveMedicationNames(request.dryAntibioticKeys(), medications), resolveMedicationNames(request.dryAntibioticKeys(), medications),
blankToNull(request.farmerNote()), blankToNull(request.farmerNote()),
blankToNull(request.internalNote()) blankToNull(request.internalNote()),
blankToNull(request.inUdderCount()),
blankToNull(request.inUdderDuration()),
blankToNull(request.systemicCount()),
blankToNull(request.systemicDuration()),
blankToNull(request.systemicDosage()),
blankToNull(request.systemicLocation()),
request.startvacVaccination(),
request.noAntibioticTreatment()
); );
Sample saved = sampleRepository.save(new Sample( Sample saved = sampleRepository.save(new Sample(
@@ -421,7 +483,10 @@ public class SampleService {
LocalDateTime.now(), LocalDateTime.now(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
return toDetail(saved); return toDetail(saved);
} }
@@ -450,7 +515,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
} }
@@ -478,7 +546,10 @@ public class SampleService {
existing.completedAt(), existing.completedAt(),
existing.ownerAccountId(), existing.ownerAccountId(),
existing.createdByUserCode(), existing.createdByUserCode(),
existing.createdByDisplayName() existing.createdByDisplayName(),
existing.pretreatment(),
existing.clinicalExamDate(),
existing.internalNote()
)); ));
} }
@@ -643,7 +714,10 @@ public class SampleService {
sample.completedAt(), sample.completedAt(),
resolvedAccountId, resolvedAccountId,
sample.createdByUserCode(), sample.createdByUserCode(),
sample.createdByDisplayName() sample.createdByDisplayName(),
sample.pretreatment(),
sample.clinicalExamDate(),
sample.internalNote()
)); ));
} }
} }
@@ -737,7 +811,10 @@ public class SampleService {
SampleWorkflowRules.canEditAnamnesis(sample), SampleWorkflowRules.canEditAnamnesis(sample),
SampleWorkflowRules.canEditAntibiogram(sample), SampleWorkflowRules.canEditAntibiogram(sample),
SampleWorkflowRules.canEditTherapy(sample), SampleWorkflowRules.canEditTherapy(sample),
sample.currentStep() == SampleWorkflowStep.COMPLETED sample.currentStep() == SampleWorkflowStep.COMPLETED,
sample.pretreatment(),
sample.clinicalExamDate(),
sample.internalNote()
); );
} }
@@ -759,7 +836,15 @@ public class SampleService {
therapy.dryAntibioticKeys(), therapy.dryAntibioticKeys(),
therapy.dryAntibioticNames(), therapy.dryAntibioticNames(),
therapy.farmerNote(), therapy.farmerNote(),
therapy.internalNote() therapy.internalNote(),
therapy.inUdderCount(),
therapy.inUdderDuration(),
therapy.systemicCount(),
therapy.systemicDuration(),
therapy.systemicDosage(),
therapy.systemicLocation(),
therapy.startvacVaccination() != null ? therapy.startvacVaccination() : Boolean.FALSE,
therapy.noAntibioticTreatment() != null ? therapy.noAntibioticTreatment() : Boolean.FALSE
); );
} }
@@ -877,7 +962,15 @@ public class SampleService {
List<String> dryAntibioticKeys, List<String> dryAntibioticKeys,
List<String> dryAntibioticNames, List<String> dryAntibioticNames,
String farmerNote, String farmerNote,
String internalNote String internalNote,
String inUdderCount,
String inUdderDuration,
String systemicCount,
String systemicDuration,
String systemicDosage,
String systemicLocation,
Boolean startvacVaccination,
Boolean noAntibioticTreatment
) { ) {
} }
@@ -909,7 +1002,10 @@ public class SampleService {
boolean anamnesisEditable, boolean anamnesisEditable,
boolean antibiogramEditable, boolean antibiogramEditable,
boolean therapyEditable, boolean therapyEditable,
boolean completed boolean completed,
Pretreatment pretreatment,
LocalDate clinicalExamDate,
String internalNote
) { ) {
} }
@@ -921,7 +1017,13 @@ public class SampleService {
SamplingMode samplingMode, SamplingMode samplingMode,
List<QuarterKey> flaggedQuarters, List<QuarterKey> flaggedQuarters,
String userCode, String userCode,
String userDisplayName String userDisplayName,
String pretreatmentInUdderInjector,
String pretreatmentSystemicAntibiotics,
String pretreatmentPainMedication,
String pretreatmentDryOffTreatment,
String clinicalExamDate,
String internalNote
) { ) {
} }
@@ -955,7 +1057,33 @@ public class SampleService {
List<String> drySealerKeys, List<String> drySealerKeys,
List<String> dryAntibioticKeys, List<String> dryAntibioticKeys,
String farmerNote, String farmerNote,
String internalNote String internalNote,
String inUdderCount,
String inUdderDuration,
String systemicCount,
String systemicDuration,
String systemicDosage,
String systemicLocation,
Boolean startvacVaccination,
Boolean noAntibioticTreatment
) { ) {
} }
private LocalDate parseClinicalExamDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) {
return null;
}
try {
// Try ISO format first (YYYY-MM-DD)
return LocalDate.parse(dateStr);
} catch (DateTimeParseException e) {
try {
// Try German format (DD.MM.YYYY)
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy");
return LocalDate.parse(dateStr, formatter);
} catch (DateTimeParseException e2) {
return null;
}
}
}
} }

View File

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

View File

@@ -9,6 +9,13 @@ export type QuarterKey =
| "LEFT_REAR" | "LEFT_REAR"
| "RIGHT_REAR"; | "RIGHT_REAR";
export type PathogenKind = "BACTERIAL" | "NO_GROWTH" | "CONTAMINATED" | "OTHER"; export type PathogenKind = "BACTERIAL" | "NO_GROWTH" | "CONTAMINATED" | "OTHER";
export interface Pretreatment {
inUdderInjector: string | null;
systemicAntibiotics: string | null;
painMedication: string | null;
dryOffTreatment: string | null;
}
export type SensitivityResult = "SENSITIVE" | "INTERMEDIATE" | "RESISTANT"; export type SensitivityResult = "SENSITIVE" | "INTERMEDIATE" | "RESISTANT";
export type MedicationCategory = export type MedicationCategory =
| "IN_UDDER" | "IN_UDDER"
@@ -145,6 +152,14 @@ export interface TherapyView {
dryAntibioticNames: string[]; dryAntibioticNames: string[];
farmerNote: string | null; farmerNote: string | null;
internalNote: string | null; internalNote: string | null;
inUdderCount: string | null;
inUdderDuration: string | null;
systemicCount: string | null;
systemicDuration: string | null;
systemicDosage: string | null;
systemicLocation: string | null;
startvacVaccination: boolean | null;
noAntibioticTreatment: boolean | null;
} }
export interface SampleDetail { export interface SampleDetail {
@@ -176,6 +191,9 @@ export interface SampleDetail {
antibiogramEditable: boolean; antibiogramEditable: boolean;
therapyEditable: boolean; therapyEditable: boolean;
completed: boolean; completed: boolean;
pretreatment: Pretreatment | null;
clinicalExamDate: string | null;
internalNote: string | null;
} }
export interface FarmerRow { export interface FarmerRow {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -624,10 +624,17 @@ a {
} }
.matrix-button { .matrix-button {
width: 42px; min-width: 90px;
width: auto;
height: 42px; height: 42px;
padding: 0 16px;
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
white-space: nowrap;
}
.matrix-col {
text-align: center !important;
} }
.eye-button { .eye-button {
@@ -1489,3 +1496,71 @@ a {
height: 72vh; height: 72vh;
} }
} }
/* Additional styles for new Lua features */
/* Checkbox field styling */
.field--checkbox {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
}
.field--checkbox input[type="checkbox"] {
width: 20px;
height: 20px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--accent);
}
.field--checkbox span {
font-size: 0.9rem;
color: var(--text);
line-height: 1.5;
}
/* Special pathogen buttons (NO_GROWTH, CONTAMINATED) */
.pathogen-button.special-pathogen {
background: rgba(138, 101, 0, 0.1);
border: 1px solid rgba(138, 101, 0, 0.2);
}
.pathogen-button.special-pathogen.is-selected {
background: linear-gradient(135deg, rgba(138, 101, 0, 0.25), rgba(138, 101, 0, 0.1));
box-shadow: inset 0 0 0 1px rgba(138, 101, 0, 0.4);
}
/* Therapy detail fields */
.field-grid--2col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 640px) {
.field-grid--2col {
grid-template-columns: 1fr;
}
}
/* Disabled state for therapy when "Keine" is selected */
.choice-chip:disabled,
.field input:disabled,
.field select:disabled,
.field textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Pretreatment section styling */
.pretreatment-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 640px) {
.pretreatment-grid {
grid-template-columns: 1fr;
}
}