Files
muh/frontend/src/pages/PortalPage.tsx

430 lines
15 KiB
TypeScript

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>
);
}