feat: extend farmer data model with complete address fields and customer number

Backend:
- Add customerNumber, companyName, contactPerson, street, houseNumber,
  postalCode, city, phoneNumber to Farmer domain model
- Update FarmerRow and FarmerMutation records with new fields
- Update repositories, services and controllers for new farmer structure
- Fix CORS configuration to allow credentials
- Remove unused authorizationService and imports
- Update data migration for new farmer schema

Frontend:
- Update FarmerRow and FarmerOption interfaces
- Extend AdministrationPage with new farmer form fields
- Update SampleRegistrationPage and SearchFarmerPage for new structure
This commit is contained in:
2026-03-18 21:13:29 +01:00
parent f9b83a166d
commit e7a18cd339
12 changed files with 464 additions and 128 deletions

View File

@@ -27,7 +27,8 @@ export type UserRole = "ADMIN" | "CUSTOMER";
export interface FarmerOption {
businessKey: string;
name: string;
companyName: string;
contactPerson: string | null;
email: string | null;
}
@@ -204,8 +205,15 @@ export interface SampleDetail {
export interface FarmerRow {
id: string;
businessKey: string;
name: string;
customerNumber: string | null;
companyName: string;
contactPerson: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
phoneNumber: string | null;
active: boolean;
updatedAt: string;
}

View File

@@ -8,13 +8,23 @@ type DatasetKey = "farmers" | "medications" | "pathogens" | "antibiotics";
type EditableRow = {
id: string;
businessKey: string;
name: string;
active: boolean;
updatedAt: string;
// Farmer fields
customerNumber?: string;
companyName?: string;
contactPerson?: string;
street?: string;
houseNumber?: string;
postalCode?: string;
city?: string;
email?: string;
phoneNumber?: string;
// Other fields
name?: string;
category?: MedicationCategory;
code?: string;
kind?: PathogenKind;
active: boolean;
updatedAt: string;
};
type DatasetsState = Record<DatasetKey, EditableRow[]>;
@@ -33,13 +43,51 @@ const DATASET_TITLES: Record<DatasetKey, string> = {
antibiotics: "Die Verwaltung der Antibiogramme",
};
const FARMER_REQUIRED_FIELDS: Array<keyof EditableRow> = [
"companyName",
"customerNumber",
"contactPerson",
"street",
"houseNumber",
"postalCode",
"city",
"email",
"phoneNumber",
];
function isBlankValue(value: string | undefined) {
return !value?.trim();
}
function isFarmerFieldInvalid(row: EditableRow, field: keyof EditableRow, showValidation: boolean) {
if (!showValidation) {
return false;
}
const value = row[field];
return typeof value !== "string" || isBlankValue(value);
}
function isFarmerRowIncomplete(row: EditableRow) {
return FARMER_REQUIRED_FIELDS.some((field) => {
const value = row[field];
return typeof value !== "string" || isBlankValue(value);
});
}
function normalizeOverview(overview: AdministrationOverview): DatasetsState {
return {
farmers: overview.farmers.map((entry) => ({
id: entry.id,
businessKey: entry.businessKey,
name: entry.name,
customerNumber: entry.customerNumber ?? "",
companyName: entry.companyName,
contactPerson: entry.contactPerson ?? "",
street: entry.street ?? "",
houseNumber: entry.houseNumber ?? "",
postalCode: entry.postalCode ?? "",
city: entry.city ?? "",
email: entry.email ?? "",
phoneNumber: entry.phoneNumber ?? "",
active: entry.active,
updatedAt: entry.updatedAt,
})),
@@ -74,7 +122,21 @@ function normalizeOverview(overview: AdministrationOverview): DatasetsState {
function emptyRow(dataset: DatasetKey): EditableRow {
switch (dataset) {
case "farmers":
return { id: "", businessKey: "", name: "", email: "", active: true, updatedAt: new Date().toISOString() };
return {
id: "",
businessKey: "",
customerNumber: "",
companyName: "",
contactPerson: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
active: true,
updatedAt: new Date().toISOString()
};
case "medications":
return {
id: "",
@@ -133,6 +195,7 @@ export default function AdministrationPage() {
}, []);
const rows = useMemo(() => datasets?.[selectedDataset] ?? [], [datasets, selectedDataset]);
const isFarmerDataset = selectedDataset === "farmers";
function updateRow(index: number, patch: Partial<EditableRow>) {
setDatasets((current) => {
@@ -166,7 +229,11 @@ export default function AdministrationPage() {
return;
}
setShowValidation(true);
if (rows.some((row) => !row.name.trim())) {
if (selectedDataset === "farmers" && rows.some(isFarmerRowIncomplete)) {
setMessage("Bitte alle Pflichtfelder fuer den Landwirt ausfuellen.");
return;
}
if (selectedDataset !== "farmers" && rows.some((row) => !row.name?.trim())) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
@@ -178,8 +245,15 @@ export default function AdministrationPage() {
case "farmers":
response = await apiPost<EditableRow[]>("/catalog/farmers", rows.map((row) => ({
id: row.id || null,
name: row.name,
customerNumber: row.customerNumber || null,
companyName: row.companyName,
contactPerson: row.contactPerson || null,
street: row.street || null,
houseNumber: row.houseNumber || null,
postalCode: row.postalCode || null,
city: row.city || null,
email: row.email || null,
phoneNumber: row.phoneNumber || null,
active: row.active,
})));
break;
@@ -245,87 +319,198 @@ export default function AdministrationPage() {
</div>
</div>
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th className="required-label">Name</th>
{selectedDataset === "farmers" ? <th>E-Mail</th> : null}
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
{selectedDataset === "pathogens" ? <th>Typ</th> : null}
<th>Aktiv</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.id || "new"}-${index}`}>
<td>
{isFarmerDataset ? (
<div className="admin-farmer-list">
{rows.map((row, index) => (
<article key={`${row.id || "new"}-${index}`} className="admin-farmer-card">
<div className="admin-farmer-card__row admin-farmer-card__row--primary">
<label className="field field--required">
<span>Firmenname</span>
<input
className={showValidation && !row.name.trim() ? "is-invalid" : ""}
value={row.name}
onChange={(event) => updateRow(index, { name: event.target.value })}
className={isFarmerFieldInvalid(row, "companyName", showValidation) ? "is-invalid" : ""}
value={row.companyName ?? ""}
onChange={(event) => updateRow(index, { companyName: event.target.value })}
placeholder="Firmenname"
required
/>
</td>
{selectedDataset === "farmers" ? (
<td>
<input
value={row.email ?? ""}
onChange={(event) => updateRow(index, { email: event.target.value })}
/>
</td>
) : null}
{selectedDataset === "medications" ? (
<td>
<select
value={row.category}
onChange={(event) =>
updateRow(index, { category: event.target.value as MedicationCategory })
}
>
<option value="IN_UDDER">ins Euter</option>
<option value="SYSTEMIC_ANTIBIOTIC">systemisch Antibiotika</option>
<option value="SYSTEMIC_PAIN">systemisch Schmerzmittel</option>
<option value="DRY_SEALER">Versiegler</option>
<option value="DRY_ANTIBIOTIC">Trockenstellerprobe Antibiotika</option>
</select>
</td>
) : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? (
<td>
<input
value={row.code ?? ""}
onChange={(event) => updateRow(index, { code: event.target.value })}
/>
</td>
) : null}
{selectedDataset === "pathogens" ? (
<td>
<select
value={row.kind}
onChange={(event) => updateRow(index, { kind: event.target.value as PathogenKind })}
>
<option value="BACTERIAL">bakteriell</option>
<option value="NO_GROWTH">kein Wachstum</option>
<option value="CONTAMINATED">verunreinigt</option>
<option value="OTHER">sonstiges</option>
</select>
</td>
) : null}
<td>
</label>
<label className="field field--required">
<span>Ansprechpartner</span>
<input
className={isFarmerFieldInvalid(row, "contactPerson", showValidation) ? "is-invalid" : ""}
value={row.contactPerson ?? ""}
onChange={(event) => updateRow(index, { contactPerson: event.target.value })}
placeholder="Ansprechpartner"
required
/>
</label>
<label className="field field--required">
<span>Kunden-Nr.</span>
<input
className={isFarmerFieldInvalid(row, "customerNumber", showValidation) ? "is-invalid" : ""}
value={row.customerNumber ?? ""}
onChange={(event) => updateRow(index, { customerNumber: event.target.value })}
placeholder="Kunden-Nr."
required
/>
</label>
</div>
<div className="admin-farmer-card__row admin-farmer-card__row--address">
<label className="field field--required">
<span>PLZ</span>
<input
className={isFarmerFieldInvalid(row, "postalCode", showValidation) ? "is-invalid" : ""}
value={row.postalCode ?? ""}
onChange={(event) => updateRow(index, { postalCode: event.target.value })}
placeholder="PLZ"
required
/>
</label>
<label className="field field--required">
<span>Ort</span>
<input
className={isFarmerFieldInvalid(row, "city", showValidation) ? "is-invalid" : ""}
value={row.city ?? ""}
onChange={(event) => updateRow(index, { city: event.target.value })}
placeholder="Ort"
required
/>
</label>
<label className="field field--required">
<span>Straße</span>
<input
className={isFarmerFieldInvalid(row, "street", showValidation) ? "is-invalid" : ""}
value={row.street ?? ""}
onChange={(event) => updateRow(index, { street: event.target.value })}
placeholder="Straße"
required
/>
</label>
<label className="field field--required">
<span>Hausnummer</span>
<input
className={isFarmerFieldInvalid(row, "houseNumber", showValidation) ? "is-invalid" : ""}
value={row.houseNumber ?? ""}
onChange={(event) => updateRow(index, { houseNumber: event.target.value })}
placeholder="Hausnummer"
required
/>
</label>
</div>
<div className="admin-farmer-card__row admin-farmer-card__row--contact">
<label className="field field--required">
<span>E-Mail</span>
<input
className={isFarmerFieldInvalid(row, "email", showValidation) ? "is-invalid" : ""}
value={row.email ?? ""}
onChange={(event) => updateRow(index, { email: event.target.value })}
placeholder="E-Mail"
required
/>
</label>
<label className="field field--required">
<span>Telefon</span>
<input
className={isFarmerFieldInvalid(row, "phoneNumber", showValidation) ? "is-invalid" : ""}
value={row.phoneNumber ?? ""}
onChange={(event) => updateRow(index, { phoneNumber: event.target.value })}
placeholder="Telefon"
required
/>
</label>
</div>
<div className="admin-farmer-card__row admin-farmer-card__row--toggle">
<label className="field">
<span>Aktiv</span>
<button
type="button"
className={`eye-button ${row.active ? "is-active" : "is-inactive"}`}
className={`eye-button admin-farmer-card__toggle ${row.active ? "is-active" : "is-inactive"}`}
onClick={() => updateRow(index, { active: !row.active })}
>
{row.active ? "sichtbar" : "inaktiv"}
</button>
</td>
</label>
</div>
</article>
))}
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th className="required-label">Name</th>
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
{selectedDataset === "pathogens" ? <th>Typ</th> : null}
<th>Aktiv</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.id || "new"}-${index}`}>
<td>
<input
className={showValidation && !row.name?.trim() ? "is-invalid" : ""}
value={row.name ?? ""}
onChange={(event) => updateRow(index, { name: event.target.value })}
/>
</td>
{selectedDataset === "medications" ? (
<td>
<select
value={row.category}
onChange={(event) =>
updateRow(index, { category: event.target.value as MedicationCategory })
}
>
<option value="IN_UDDER">ins Euter</option>
<option value="SYSTEMIC_ANTIBIOTIC">systemisch Antibiotika</option>
<option value="SYSTEMIC_PAIN">systemisch Schmerzmittel</option>
<option value="DRY_SEALER">Versiegler</option>
<option value="DRY_ANTIBIOTIC">Trockenstellerprobe Antibiotika</option>
</select>
</td>
) : null}
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? (
<td>
<input
value={row.code ?? ""}
onChange={(event) => updateRow(index, { code: event.target.value })}
/>
</td>
) : null}
{selectedDataset === "pathogens" ? (
<td>
<select
value={row.kind}
onChange={(event) => updateRow(index, { kind: event.target.value as PathogenKind })}
>
<option value="BACTERIAL">bakteriell</option>
<option value="NO_GROWTH">kein Wachstum</option>
<option value="CONTAMINATED">verunreinigt</option>
<option value="OTHER">sonstiges</option>
</select>
</td>
) : null}
<td>
<button
type="button"
className={`eye-button ${row.active ? "is-active" : "is-inactive"}`}
onClick={() => updateRow(index, { active: !row.active })}
>
{row.active ? "sichtbar" : "inaktiv"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="page-actions page-actions--space-between">
<button type="button" className="secondary-button" onClick={addRow}>

View File

@@ -196,7 +196,7 @@ export default function SampleRegistrationPage() {
>
{catalogs?.farmers.map((farmer) => (
<option key={farmer.businessKey} value={farmer.businessKey}>
{farmer.name}
{farmer.companyName}
</option>
))}
</select>

View File

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

View File

@@ -570,6 +570,46 @@ a {
overflow: auto;
}
.admin-farmer-list {
display: grid;
gap: 20px;
}
.admin-farmer-card {
display: grid;
gap: 16px;
padding: 24px;
border: 1px solid rgba(37, 49, 58, 0.08);
border-radius: 24px;
background: rgba(255, 255, 255, 0.42);
}
.admin-farmer-card__row {
display: grid;
gap: 16px;
}
.admin-farmer-card__row--primary {
grid-template-columns: minmax(0, 1.8fr) minmax(0, 1.3fr) minmax(0, 1fr);
}
.admin-farmer-card__row--address {
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.05fr) minmax(0, 1.8fr) minmax(0, 0.9fr);
}
.admin-farmer-card__row--contact {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.1fr);
}
.admin-farmer-card__row--toggle {
grid-template-columns: minmax(0, 180px);
}
.admin-farmer-card__toggle {
min-width: 140px;
justify-content: center;
}
.data-table {
width: 100%;
border-collapse: collapse;
@@ -590,6 +630,35 @@ a {
text-transform: uppercase;
}
@media (max-width: 1200px) {
.admin-farmer-card__row--primary,
.admin-farmer-card__row--address,
.admin-farmer-card__row--contact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-farmer-card__row--toggle {
grid-template-columns: minmax(0, 180px);
}
.admin-farmer-card__toggle {
width: 100%;
}
}
@media (max-width: 720px) {
.admin-farmer-card {
padding: 18px;
}
.admin-farmer-card__row--primary,
.admin-farmer-card__row--address,
.admin-farmer-card__row--contact,
.admin-farmer-card__row--toggle {
grid-template-columns: 1fr;
}
}
.status-pill,
.info-chip {
display: inline-flex;