Initial MUH app implementation
This commit is contained in:
429
frontend/src/pages/PortalPage.tsx
Normal file
429
frontend/src/pages/PortalPage.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api";
|
||||
import type { PortalSnapshot, UserRole } from "../lib/types";
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export default function PortalPage() {
|
||||
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null);
|
||||
const [selectedFarmer, setSelectedFarmer] = useState("");
|
||||
const [farmerQuery, setFarmerQuery] = useState("");
|
||||
const [cowQuery, setCowQuery] = useState("");
|
||||
const [sampleNumberQuery, setSampleNumberQuery] = useState("");
|
||||
const [dateQuery, setDateQuery] = useState("");
|
||||
const [selectedReports, setSelectedReports] = useState<string[]>([]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [userForm, setUserForm] = useState({
|
||||
code: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
portalLogin: "",
|
||||
password: "",
|
||||
role: "APP" as UserRole,
|
||||
});
|
||||
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
async function loadSnapshot() {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedFarmer) {
|
||||
params.set("farmerBusinessKey", selectedFarmer);
|
||||
}
|
||||
if (farmerQuery) {
|
||||
params.set("farmerQuery", farmerQuery);
|
||||
}
|
||||
if (cowQuery) {
|
||||
params.set("cowQuery", cowQuery);
|
||||
}
|
||||
if (sampleNumberQuery) {
|
||||
params.set("sampleNumber", sampleNumberQuery);
|
||||
}
|
||||
if (dateQuery) {
|
||||
params.set("date", dateQuery);
|
||||
}
|
||||
|
||||
const response = await apiGet<PortalSnapshot>(`/portal/snapshot?${params.toString()}`);
|
||||
setSnapshot(response);
|
||||
setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSnapshot();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const reportCount = useMemo(() => selectedReports.length, [selectedReports]);
|
||||
|
||||
function toggleReport(sampleId: string) {
|
||||
setSelectedReports((current) =>
|
||||
current.includes(sampleId)
|
||||
? current.filter((entry) => entry !== sampleId)
|
||||
: [...current, sampleId],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSearch(event?: FormEvent) {
|
||||
event?.preventDefault();
|
||||
try {
|
||||
setMessage(null);
|
||||
await loadSnapshot();
|
||||
} catch (loadError) {
|
||||
setMessage((loadError as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDispatchReports() {
|
||||
try {
|
||||
const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", {
|
||||
sampleIds: selectedReports,
|
||||
});
|
||||
setMessage(
|
||||
response.mailDeliveryActive
|
||||
? "Berichte wurden versendet."
|
||||
: "Berichte wurden als versendet markiert. Fuer echten Mailversand fehlt noch SMTP-Konfiguration.",
|
||||
);
|
||||
await loadSnapshot();
|
||||
} catch (dispatchError) {
|
||||
setMessage((dispatchError as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await apiPost("/portal/users", {
|
||||
...userForm,
|
||||
active: true,
|
||||
});
|
||||
setUserForm({
|
||||
code: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
portalLogin: "",
|
||||
password: "",
|
||||
role: "APP",
|
||||
});
|
||||
setMessage("Benutzer gespeichert.");
|
||||
await loadSnapshot();
|
||||
} catch (userError) {
|
||||
setMessage((userError as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(userId: string) {
|
||||
try {
|
||||
await apiDelete(`/portal/users/${userId}`);
|
||||
setMessage("Benutzer geloescht.");
|
||||
await loadSnapshot();
|
||||
} catch (deleteError) {
|
||||
setMessage((deleteError as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordChange(userId: string) {
|
||||
try {
|
||||
await apiPost(`/portal/users/${userId}/password`, {
|
||||
password: passwordDrafts[userId],
|
||||
});
|
||||
setPasswordDrafts((current) => ({ ...current, [userId]: "" }));
|
||||
setMessage("Passwort aktualisiert.");
|
||||
} catch (passwordError) {
|
||||
setMessage((passwordError as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleBlocked(sampleId: string, blocked: boolean) {
|
||||
try {
|
||||
await apiPatch(`/portal/reports/${sampleId}/block`, { blocked });
|
||||
await loadSnapshot();
|
||||
} catch (blockError) {
|
||||
setMessage((blockError as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return <div className="empty-state">Portal wird geladen ...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-card section-card--hero">
|
||||
<div>
|
||||
<p className="eyebrow">MUH-Portal</p>
|
||||
<h3>Benutzer, Berichtversand und Schnellsuche</h3>
|
||||
<p className="muted-text">
|
||||
Das Portal kombiniert Verwaltungsfunktionen mit dem Versandstatus aller
|
||||
abgeschlossenen Proben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className={message.includes("gespeichert") || message.includes("versendet") || message.includes("aktualisiert") || message.includes("geloescht") ? "alert alert--success" : "alert alert--error"}>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="portal-grid">
|
||||
<article className="section-card">
|
||||
<div className="section-card__header">
|
||||
<div>
|
||||
<p className="eyebrow">Bericht-Versand</p>
|
||||
<h3>Versandbereite Proben</h3>
|
||||
</div>
|
||||
<div className="info-chip">{reportCount} markiert</div>
|
||||
</div>
|
||||
|
||||
<div className="check-list">
|
||||
{snapshot.reportCandidates.map((candidate) => (
|
||||
<label key={candidate.sampleId} className="check-list__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReports.includes(candidate.sampleId)}
|
||||
onChange={() => toggleReport(candidate.sampleId)}
|
||||
/>
|
||||
<span>
|
||||
Probe {candidate.sampleNumber} | {candidate.farmerName} | {candidate.farmerEmail}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="page-actions">
|
||||
<button type="button" className="accent-button" onClick={() => void handleDispatchReports()}>
|
||||
Markierte Mails versenden
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="section-card">
|
||||
<p className="eyebrow">Benutzerverwaltung</p>
|
||||
<form className="field-grid" onSubmit={handleCreateUser}>
|
||||
<label className="field">
|
||||
<span>Kuerzel</span>
|
||||
<input
|
||||
value={userForm.code}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
value={userForm.displayName}
|
||||
onChange={(event) =>
|
||||
setUserForm((current) => ({ ...current, displayName: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Login</span>
|
||||
<input
|
||||
value={userForm.portalLogin}
|
||||
onChange={(event) =>
|
||||
setUserForm((current) => ({ ...current, portalLogin: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>E-Mail</span>
|
||||
<input
|
||||
type="email"
|
||||
value={userForm.email}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Passwort</span>
|
||||
<input
|
||||
value={userForm.password}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Rolle</span>
|
||||
<select
|
||||
value={userForm.role}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))}
|
||||
>
|
||||
<option value="APP">APP</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="page-actions">
|
||||
<button type="submit" className="accent-button">
|
||||
Benutzer anlegen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kuerzel</th>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Login</th>
|
||||
<th>Rolle</th>
|
||||
<th>Passwort</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshot.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.code}</td>
|
||||
<td>{user.displayName}</td>
|
||||
<td>{user.email ?? "-"}</td>
|
||||
<td>{user.portalLogin ?? "-"}</td>
|
||||
<td>{user.role}</td>
|
||||
<td>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordDrafts[user.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
|
||||
}
|
||||
placeholder="Neues Passwort"
|
||||
/>
|
||||
</td>
|
||||
<td className="table-actions">
|
||||
<button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="section-card">
|
||||
<form className="field-grid" onSubmit={handleSearch}>
|
||||
<label className="field">
|
||||
<span>Landwirt suchen</span>
|
||||
<input value={farmerQuery} onChange={(event) => setFarmerQuery(event.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Gefundener Landwirt</span>
|
||||
<select value={selectedFarmer} onChange={(event) => setSelectedFarmer(event.target.value)}>
|
||||
<option value="">alle / noch keiner</option>
|
||||
{snapshot.farmers.map((farmer) => (
|
||||
<option key={farmer.businessKey} value={farmer.businessKey}>
|
||||
{farmer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Kuh</span>
|
||||
<input value={cowQuery} onChange={(event) => setCowQuery(event.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Probe-Nr.</span>
|
||||
<input value={sampleNumberQuery} onChange={(event) => setSampleNumberQuery(event.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Datum</span>
|
||||
<input type="date" value={dateQuery} onChange={(event) => setDateQuery(event.target.value)} />
|
||||
</label>
|
||||
<div className="page-actions page-actions--align-end">
|
||||
<button type="submit" className="accent-button">
|
||||
Suche starten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
setSelectedFarmer("");
|
||||
setFarmerQuery("");
|
||||
setCowQuery("");
|
||||
setSampleNumberQuery("");
|
||||
setDateQuery("");
|
||||
void handleSearch();
|
||||
}}
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="section-card">
|
||||
<div className="section-card__header">
|
||||
<div>
|
||||
<p className="eyebrow">Suchergebnis</p>
|
||||
<h3>Gefundene Milchproben</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Probe</th>
|
||||
<th>Anlage</th>
|
||||
<th>Landwirt</th>
|
||||
<th>Kuh</th>
|
||||
<th>Typ</th>
|
||||
<th>Interne Bemerkung</th>
|
||||
<th>PDF</th>
|
||||
<th>Versand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshot.samples.map((sample) => (
|
||||
<tr key={sample.sampleId}>
|
||||
<td>{sample.sampleNumber}</td>
|
||||
<td>{formatDate(sample.createdAt)}</td>
|
||||
<td>{sample.farmerName}</td>
|
||||
<td>{sample.cowNumber}{sample.cowName ? ` / ${sample.cowName}` : ""}</td>
|
||||
<td>{sample.sampleKindLabel === "DRY_OFF" ? "Trockensteller" : "Milchprobe"}</td>
|
||||
<td>{sample.internalNote ?? "-"}</td>
|
||||
<td>
|
||||
{sample.completed ? (
|
||||
<a className="table-link" href={pdfUrl(sample.sampleId)} target="_blank" rel="noreferrer">
|
||||
PDF
|
||||
</a>
|
||||
) : (
|
||||
<span className="muted-text">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
<span className={`status-pill ${sample.reportSent ? "status-pill--completed" : "status-pill--therapy"}`}>
|
||||
{sample.reportSent ? "versendet" : "offen"}
|
||||
</span>
|
||||
{sample.completed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="table-link"
|
||||
onClick={() => void handleToggleBlocked(sample.sampleId, !sample.reportBlocked)}
|
||||
>
|
||||
{sample.reportBlocked ? "freigeben" : "blockieren"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user