Add customer search and navigation updates
This commit is contained in:
@@ -16,6 +16,8 @@ public interface SampleRepository extends MongoRepository<Sample, String> {
|
|||||||
|
|
||||||
List<Sample> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey);
|
List<Sample> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey);
|
||||||
|
|
||||||
|
List<Sample> findByCreatedAtBetweenOrderByCreatedAtDesc(LocalDateTime start, LocalDateTime end);
|
||||||
|
|
||||||
List<Sample> findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end);
|
List<Sample> findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end);
|
||||||
|
|
||||||
List<Sample> findByCompletedAtNotNullOrderByCompletedAtDesc();
|
List<Sample> findByCompletedAtNotNullOrderByCompletedAtDesc();
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ public class PortalService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<PortalSampleRow> searchSamplesByCreatedDate(LocalDate date) {
|
||||||
|
return sampleService.samplesByCreatedDate(date).stream()
|
||||||
|
.map(this::toPortalRow)
|
||||||
|
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean cowMatches(Sample sample, String cowQuery) {
|
private boolean cowMatches(Sample sample, String cowQuery) {
|
||||||
String query = cowQuery.toLowerCase(Locale.ROOT);
|
String query = cowQuery.toLowerCase(Locale.ROOT);
|
||||||
return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query))
|
return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query))
|
||||||
|
|||||||
@@ -456,6 +456,10 @@ public class SampleService {
|
|||||||
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey);
|
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Sample> samplesByCreatedDate(LocalDate date) {
|
||||||
|
return sampleRepository.findByCreatedAtBetweenOrderByCreatedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay());
|
||||||
|
}
|
||||||
|
|
||||||
public List<Sample> samplesByDate(LocalDate date) {
|
public List<Sample> samplesByDate(LocalDate date) {
|
||||||
return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay());
|
return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ public class PortalController {
|
|||||||
return reportService.reportCandidates();
|
return reportService.reportCandidates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search/by-date")
|
||||||
|
public List<PortalService.PortalSampleRow> searchByDate(@RequestParam LocalDate date) {
|
||||||
|
return portalService.searchSamplesByCreatedDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/reports/send")
|
@PostMapping("/reports/send")
|
||||||
public ReportService.DispatchResult send(
|
public ReportService.DispatchResult send(
|
||||||
@RequestHeader(value = "X-MUH-Actor-Id", required = false) String actorId,
|
@RequestHeader(value = "X-MUH-Actor-Id", required = false) String actorId,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import AntibiogramPage from "./pages/AntibiogramPage";
|
|||||||
import TherapyPage from "./pages/TherapyPage";
|
import TherapyPage from "./pages/TherapyPage";
|
||||||
import AdministrationPage from "./pages/AdministrationPage";
|
import AdministrationPage from "./pages/AdministrationPage";
|
||||||
import PortalPage from "./pages/PortalPage";
|
import PortalPage from "./pages/PortalPage";
|
||||||
|
import SearchPage from "./pages/SearchPage";
|
||||||
|
import SearchFarmerPage from "./pages/SearchFarmerPage";
|
||||||
|
import SearchCalendarPage from "./pages/SearchCalendarPage";
|
||||||
|
|
||||||
function ProtectedRoutes() {
|
function ProtectedRoutes() {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@@ -26,7 +29,15 @@ function ProtectedRoutes() {
|
|||||||
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
|
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
|
||||||
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
|
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
|
||||||
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
|
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
|
||||||
<Route path="/admin" element={<AdministrationPage />} />
|
<Route path="/admin" element={<Navigate to="/admin/landwirte" replace />} />
|
||||||
|
<Route path="/admin/landwirte" element={<AdministrationPage />} />
|
||||||
|
<Route path="/admin/medikamente" element={<AdministrationPage />} />
|
||||||
|
<Route path="/admin/erreger" element={<AdministrationPage />} />
|
||||||
|
<Route path="/admin/antibiogramm" element={<AdministrationPage />} />
|
||||||
|
<Route path="/search" element={<Navigate to="/search/probe" replace />} />
|
||||||
|
<Route path="/search/probe" element={<SearchPage />} />
|
||||||
|
<Route path="/search/landwirt" element={<SearchFarmerPage />} />
|
||||||
|
<Route path="/search/kalendar" element={<SearchCalendarPage />} />
|
||||||
<Route path="/portal" element={<PortalPage />} />
|
<Route path="/portal" element={<PortalPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||||
|
|||||||
79
frontend/src/components/SampleSearchResultsSection.tsx
Normal file
79
frontend/src/components/SampleSearchResultsSection.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { PortalSampleRow } 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
type SampleSearchResultsSectionProps = {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
emptyText: string;
|
||||||
|
samples: PortalSampleRow[];
|
||||||
|
onOpen: (sampleNumber: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SampleSearchResultsSection({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
emptyText,
|
||||||
|
samples,
|
||||||
|
onOpen,
|
||||||
|
}: SampleSearchResultsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{eyebrow}</p>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!samples.length ? (
|
||||||
|
<div className="empty-state">{emptyText}</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-shell">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Probe</th>
|
||||||
|
<th>Erfasst</th>
|
||||||
|
<th>Landwirt</th>
|
||||||
|
<th>Kuh</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Interne Bemerkung</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="table-link"
|
||||||
|
onClick={() => onOpen(sample.sampleNumber)}
|
||||||
|
>
|
||||||
|
Oeffnen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useSession } from "../lib/session";
|
|||||||
const PAGE_TITLES: Record<string, string> = {
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
"/home": "Startseite",
|
"/home": "Startseite",
|
||||||
"/samples/new": "Neuanlage einer Probe",
|
"/samples/new": "Neuanlage einer Probe",
|
||||||
"/admin": "Verwaltung",
|
|
||||||
"/portal": "MUH-Portal",
|
"/portal": "MUH-Portal",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,6 +20,27 @@ function resolvePageTitle(pathname: string) {
|
|||||||
if (pathname.includes("/registration")) {
|
if (pathname.includes("/registration")) {
|
||||||
return "Probe bearbeiten";
|
return "Probe bearbeiten";
|
||||||
}
|
}
|
||||||
|
if (pathname.startsWith("/admin/landwirte")) {
|
||||||
|
return "Verwaltung | Landwirte";
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/admin/medikamente")) {
|
||||||
|
return "Verwaltung | Medikamente";
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/admin/erreger")) {
|
||||||
|
return "Verwaltung | Erreger";
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/admin/antibiogramm")) {
|
||||||
|
return "Verwaltung | Antibiogramm";
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/search/landwirt")) {
|
||||||
|
return "Suche | Landwirt";
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/search/probe")) {
|
||||||
|
return "Suche | Probe";
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/search/kalendar")) {
|
||||||
|
return "Suche | Kalendar";
|
||||||
|
}
|
||||||
return PAGE_TITLES[pathname] ?? "MUH App";
|
return PAGE_TITLES[pathname] ?? "MUH App";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,14 +48,6 @@ export default function AppShell() {
|
|||||||
const { user, setUser } = useSession();
|
const { user, setUser } = useSession();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navItems = user?.role === "ADMIN"
|
|
||||||
? [{ to: "/portal", label: "Benutzerverwaltung" }]
|
|
||||||
: [
|
|
||||||
{ to: "/home", label: "Start" },
|
|
||||||
{ to: "/samples/new", label: "Neue Probe" },
|
|
||||||
{ to: "/admin", label: "Verwaltung" },
|
|
||||||
{ to: "/portal", label: "Portal" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
@@ -45,15 +57,60 @@ export default function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="sidebar__nav">
|
<nav className="sidebar__nav">
|
||||||
{navItems.map((item) => (
|
{user?.role === "ADMIN" ? (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
to="/portal"
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
|
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
Benutzerverwaltung
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
) : (
|
||||||
|
<>
|
||||||
|
<NavLink to="/home" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
|
||||||
|
Start
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/samples/new" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
|
||||||
|
Neue Probe
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<div className="nav-group">
|
||||||
|
<div className="nav-group__label">Verwaltung</div>
|
||||||
|
<div className="nav-subnav">
|
||||||
|
<NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||||
|
Landwirte
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin/medikamente" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||||
|
Medikamente
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin/erreger" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||||
|
Erreger
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin/antibiogramm" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||||
|
Antibiogramm
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nav-group">
|
||||||
|
<div className="nav-group__label">Suche</div>
|
||||||
|
<div className="nav-subnav">
|
||||||
|
<NavLink to="/search/landwirt" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||||
|
Landwirt
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/search/probe" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||||
|
Probe
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/search/kalendar" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||||
|
Kalendar
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NavLink to="/portal" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
|
||||||
|
Portal
|
||||||
|
</NavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar__footer">
|
<div className="sidebar__footer">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { apiGet, apiPost } from "../lib/api";
|
import { apiGet, apiPost } from "../lib/api";
|
||||||
import type { AdministrationOverview, MedicationCategory, PathogenKind } from "../lib/types";
|
import type { AdministrationOverview, MedicationCategory, PathogenKind } from "../lib/types";
|
||||||
|
|
||||||
@@ -92,10 +93,24 @@ function emptyRow(dataset: DatasetKey): EditableRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdministrationPage() {
|
export default function AdministrationPage() {
|
||||||
|
const location = useLocation();
|
||||||
const [datasets, setDatasets] = useState<DatasetsState | null>(null);
|
const [datasets, setDatasets] = useState<DatasetsState | null>(null);
|
||||||
const [selectedDataset, setSelectedDataset] = useState<DatasetKey>("farmers");
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [showValidation, setShowValidation] = useState(false);
|
||||||
|
|
||||||
|
const selectedDataset: DatasetKey = useMemo(() => {
|
||||||
|
if (location.pathname.startsWith("/admin/medikamente")) {
|
||||||
|
return "medications";
|
||||||
|
}
|
||||||
|
if (location.pathname.startsWith("/admin/erreger")) {
|
||||||
|
return "pathogens";
|
||||||
|
}
|
||||||
|
if (location.pathname.startsWith("/admin/antibiogramm")) {
|
||||||
|
return "antibiotics";
|
||||||
|
}
|
||||||
|
return "farmers";
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -143,6 +158,11 @@ export default function AdministrationPage() {
|
|||||||
if (!datasets) {
|
if (!datasets) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowValidation(true);
|
||||||
|
if (rows.some((row) => !row.name.trim())) {
|
||||||
|
setMessage("Bitte alle Pflichtfelder ausfuellen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
@@ -216,26 +236,13 @@ export default function AdministrationPage() {
|
|||||||
<p className="eyebrow">Datensatz</p>
|
<p className="eyebrow">Datensatz</p>
|
||||||
<h3>{DATASET_LABELS[selectedDataset]}</h3>
|
<h3>{DATASET_LABELS[selectedDataset]}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="choice-row">
|
|
||||||
{(Object.keys(DATASET_LABELS) as DatasetKey[]).map((dataset) => (
|
|
||||||
<button
|
|
||||||
key={dataset}
|
|
||||||
type="button"
|
|
||||||
className={`choice-chip ${selectedDataset === dataset ? "is-selected" : ""}`}
|
|
||||||
onClick={() => setSelectedDataset(dataset)}
|
|
||||||
>
|
|
||||||
{DATASET_LABELS[dataset]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-shell">
|
<div className="table-shell">
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th className="required-label">Name</th>
|
||||||
{selectedDataset === "farmers" ? <th>E-Mail</th> : null}
|
{selectedDataset === "farmers" ? <th>E-Mail</th> : null}
|
||||||
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
|
{selectedDataset === "medications" ? <th>Kategorie</th> : null}
|
||||||
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
|
{selectedDataset === "pathogens" || selectedDataset === "antibiotics" ? <th>Kuerzel</th> : null}
|
||||||
@@ -248,6 +255,7 @@ export default function AdministrationPage() {
|
|||||||
<tr key={`${row.id || "new"}-${index}`}>
|
<tr key={`${row.id || "new"}-${index}`}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
className={showValidation && !row.name.trim() ? "is-invalid" : ""}
|
||||||
value={row.name}
|
value={row.name}
|
||||||
onChange={(event) => updateRow(index, { name: event.target.value })}
|
onChange={(event) => updateRow(index, { name: event.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function AnamnesisPage() {
|
|||||||
const [activeQuarter, setActiveQuarter] = useState<QuarterKey | null>(null);
|
const [activeQuarter, setActiveQuarter] = useState<QuarterKey | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showValidation, setShowValidation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -70,10 +71,22 @@ export default function AnamnesisPage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quarterHasPathogen(quarterKey: QuarterKey) {
|
||||||
|
const quarterState = quarterStates[quarterKey];
|
||||||
|
return Boolean(quarterState?.pathogenBusinessKey || quarterState?.customPathogenName?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!sampleId || !sample) {
|
if (!sampleId || !sample) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowValidation(true);
|
||||||
|
const missingQuarter = sample.quarters.find((quarter) => !quarterHasPathogen(quarter.quarterKey));
|
||||||
|
if (missingQuarter) {
|
||||||
|
setActiveQuarter(missingQuarter.quarterKey);
|
||||||
|
setMessage("Bitte fuer jede Entnahmestelle einen Erreger auswaehlen oder eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
@@ -134,7 +147,9 @@ export default function AnamnesisPage() {
|
|||||||
<button
|
<button
|
||||||
key={quarter.quarterKey}
|
key={quarter.quarterKey}
|
||||||
type="button"
|
type="button"
|
||||||
className={`tab-chip ${activeQuarter === quarter.quarterKey ? "is-active" : ""}`}
|
className={`tab-chip ${activeQuarter === quarter.quarterKey ? "is-active" : ""} ${
|
||||||
|
showValidation && !quarterHasPathogen(quarter.quarterKey) ? "is-invalid" : ""
|
||||||
|
}`}
|
||||||
onClick={() => setActiveQuarter(quarter.quarterKey)}
|
onClick={() => setActiveQuarter(quarter.quarterKey)}
|
||||||
>
|
>
|
||||||
{quarter.label}
|
{quarter.label}
|
||||||
@@ -153,7 +168,8 @@ export default function AnamnesisPage() {
|
|||||||
<div className="info-chip">Auffaelliges Viertel markiert</div>
|
<div className="info-chip">Auffaelliges Viertel markiert</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="pathogen-grid">
|
<p className="required-label">Erreger</p>
|
||||||
|
<div className={`pathogen-grid ${showValidation && !quarterHasPathogen(visibleQuarter.quarterKey) ? "is-invalid" : ""}`}>
|
||||||
{catalogs.pathogens.map((pathogen) => (
|
{catalogs.pathogens.map((pathogen) => (
|
||||||
<button
|
<button
|
||||||
key={pathogen.businessKey}
|
key={pathogen.businessKey}
|
||||||
@@ -175,9 +191,10 @@ export default function AnamnesisPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Erreger manuell eingeben</span>
|
<span>Erreger manuell eingeben</span>
|
||||||
<input
|
<input
|
||||||
|
className={showValidation && !quarterHasPathogen(visibleQuarter.quarterKey) ? "is-invalid" : ""}
|
||||||
value={state.customPathogenName}
|
value={state.customPathogenName}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateQuarter(visibleQuarter.quarterKey, {
|
updateQuarter(visibleQuarter.quarterKey, {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function HomePage() {
|
|||||||
const [sampleNumber, setSampleNumber] = useState("");
|
const [sampleNumber, setSampleNumber] = useState("");
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showValidation, setShowValidation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
@@ -43,6 +44,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
async function handleLookup(event: FormEvent<HTMLFormElement>) {
|
async function handleLookup(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setShowValidation(true);
|
||||||
if (!sampleNumber.trim()) {
|
if (!sampleNumber.trim()) {
|
||||||
setMessage("Bitte eine Probennummer eingeben.");
|
setMessage("Bitte eine Probennummer eingeben.");
|
||||||
return;
|
return;
|
||||||
@@ -72,14 +74,15 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="hero-card__form" onSubmit={handleLookup}>
|
<form className={`hero-card__form ${showValidation ? "show-validation" : ""}`} onSubmit={handleLookup}>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Nummer</span>
|
<span>Nummer</span>
|
||||||
<input
|
<input
|
||||||
value={sampleNumber}
|
value={sampleNumber}
|
||||||
onChange={(event) => setSampleNumber(event.target.value)}
|
onChange={(event) => setSampleNumber(event.target.value)}
|
||||||
placeholder="z. B. 100203"
|
placeholder="z. B. 100203"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="accent-button">
|
<button type="submit" className="accent-button">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export default function LoginPage() {
|
|||||||
const [showRegistration, setShowRegistration] = useState(false);
|
const [showRegistration, setShowRegistration] = useState(false);
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [showLoginValidation, setShowLoginValidation] = useState(false);
|
||||||
|
const [showRegisterValidation, setShowRegisterValidation] = useState(false);
|
||||||
const [registration, setRegistration] = useState({
|
const [registration, setRegistration] = useState({
|
||||||
companyName: "",
|
companyName: "",
|
||||||
street: "",
|
street: "",
|
||||||
@@ -28,6 +30,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
|
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setShowLoginValidation(true);
|
||||||
if (!identifier.trim() || !password.trim()) {
|
if (!identifier.trim() || !password.trim()) {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
type: "error",
|
type: "error",
|
||||||
@@ -49,6 +52,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
async function handleRegister(event: FormEvent<HTMLFormElement>) {
|
async function handleRegister(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setShowRegisterValidation(true);
|
||||||
if (
|
if (
|
||||||
!registration.companyName.trim()
|
!registration.companyName.trim()
|
||||||
|| !registration.street.trim()
|
|| !registration.street.trim()
|
||||||
@@ -113,21 +117,23 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="auth-grid">
|
<div className="auth-grid">
|
||||||
{!showRegistration ? (
|
{!showRegistration ? (
|
||||||
<form className="login-panel__section" onSubmit={handlePasswordLogin}>
|
<form className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`} onSubmit={handlePasswordLogin}>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>E-Mail / Benutzername</span>
|
<span>E-Mail / Benutzername</span>
|
||||||
<input
|
<input
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
placeholder="z. B. admin oder name@hof.de"
|
placeholder="z. B. admin oder name@hof.de"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Passwort</span>
|
<span>Passwort</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
@@ -139,6 +145,7 @@ export default function LoginPage() {
|
|||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
|
setShowRegisterValidation(false);
|
||||||
setShowRegistration(true);
|
setShowRegistration(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -147,9 +154,9 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form className="login-panel__section" onSubmit={handleRegister}>
|
<form className={`login-panel__section ${showRegisterValidation ? "show-validation" : ""}`} onSubmit={handleRegister}>
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field--wide">
|
<label className="field field--wide field--required">
|
||||||
<span>Firmenname</span>
|
<span>Firmenname</span>
|
||||||
<input
|
<input
|
||||||
value={registration.companyName}
|
value={registration.companyName}
|
||||||
@@ -157,9 +164,10 @@ export default function LoginPage() {
|
|||||||
setRegistration((current) => ({ ...current, companyName: event.target.value }))
|
setRegistration((current) => ({ ...current, companyName: event.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="z. B. Muster Agrar GmbH"
|
placeholder="z. B. Muster Agrar GmbH"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Strasse</span>
|
<span>Strasse</span>
|
||||||
<input
|
<input
|
||||||
value={registration.street}
|
value={registration.street}
|
||||||
@@ -167,9 +175,10 @@ export default function LoginPage() {
|
|||||||
setRegistration((current) => ({ ...current, street: event.target.value }))
|
setRegistration((current) => ({ ...current, street: event.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="z. B. Dorfstrasse"
|
placeholder="z. B. Dorfstrasse"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Hausnummer</span>
|
<span>Hausnummer</span>
|
||||||
<input
|
<input
|
||||||
value={registration.houseNumber}
|
value={registration.houseNumber}
|
||||||
@@ -177,9 +186,10 @@ export default function LoginPage() {
|
|||||||
setRegistration((current) => ({ ...current, houseNumber: event.target.value }))
|
setRegistration((current) => ({ ...current, houseNumber: event.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="z. B. 12a"
|
placeholder="z. B. 12a"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>PLZ</span>
|
<span>PLZ</span>
|
||||||
<input
|
<input
|
||||||
value={registration.postalCode}
|
value={registration.postalCode}
|
||||||
@@ -187,9 +197,10 @@ export default function LoginPage() {
|
|||||||
setRegistration((current) => ({ ...current, postalCode: event.target.value }))
|
setRegistration((current) => ({ ...current, postalCode: event.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="z. B. 12345"
|
placeholder="z. B. 12345"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<input
|
<input
|
||||||
value={registration.city}
|
value={registration.city}
|
||||||
@@ -197,9 +208,10 @@ export default function LoginPage() {
|
|||||||
setRegistration((current) => ({ ...current, city: event.target.value }))
|
setRegistration((current) => ({ ...current, city: event.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="z. B. Musterstadt"
|
placeholder="z. B. Musterstadt"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>E-Mail</span>
|
<span>E-Mail</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -207,19 +219,22 @@ export default function LoginPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setRegistration((current) => ({ ...current, email: event.target.value }))
|
setRegistration((current) => ({ ...current, email: event.target.value }))
|
||||||
}
|
}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Telefonnummer</span>
|
<span>Telefonnummer</span>
|
||||||
<input
|
<input
|
||||||
|
type="tel"
|
||||||
value={registration.phoneNumber}
|
value={registration.phoneNumber}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setRegistration((current) => ({ ...current, phoneNumber: event.target.value }))
|
setRegistration((current) => ({ ...current, phoneNumber: event.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="z. B. 04531 181424"
|
placeholder="z. B. 04531 181424"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field field--wide">
|
<label className="field field--wide field--required">
|
||||||
<span>Passwort</span>
|
<span>Passwort</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -227,19 +242,30 @@ export default function LoginPage() {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setRegistration((current) => ({ ...current, password: event.target.value }))
|
setRegistration((current) => ({ ...current, password: event.target.value }))
|
||||||
}
|
}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field field--wide">
|
<label className="field field--wide field--required">
|
||||||
<span>Passwort wiederholen</span>
|
<span>Passwort wiederholen</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={registration.passwordConfirmation}
|
value={registration.passwordConfirmation}
|
||||||
|
className={
|
||||||
|
showRegisterValidation
|
||||||
|
&& registration.password !== registration.passwordConfirmation
|
||||||
|
? "is-invalid"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
aria-invalid={
|
||||||
|
showRegisterValidation && registration.password !== registration.passwordConfirmation
|
||||||
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setRegistration((current) => ({
|
setRegistration((current) => ({
|
||||||
...current,
|
...current,
|
||||||
passwordConfirmation: event.target.value,
|
passwordConfirmation: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,6 +278,7 @@ export default function LoginPage() {
|
|||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
|
setShowLoginValidation(false);
|
||||||
setShowRegistration(false);
|
setShowRegistration(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api";
|
import { apiDelete, apiGet, apiPost } from "../lib/api";
|
||||||
import { useSession } from "../lib/session";
|
import { useSession } from "../lib/session";
|
||||||
import type { PortalSnapshot, UserRole } from "../lib/types";
|
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() {
|
export default function PortalPage() {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null);
|
||||||
@@ -27,6 +17,7 @@ export default function PortalPage() {
|
|||||||
role: "CUSTOMER" as UserRole,
|
role: "CUSTOMER" as UserRole,
|
||||||
});
|
});
|
||||||
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [showUserValidation, setShowUserValidation] = useState(false);
|
||||||
const isAdmin = user?.role === "ADMIN";
|
const isAdmin = user?.role === "ADMIN";
|
||||||
|
|
||||||
async function loadSnapshot() {
|
async function loadSnapshot() {
|
||||||
@@ -68,6 +59,7 @@ export default function PortalPage() {
|
|||||||
|
|
||||||
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setShowUserValidation(true);
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
setMessage("Nur Administratoren koennen Benutzer anlegen.");
|
setMessage("Nur Administratoren koennen Benutzer anlegen.");
|
||||||
return;
|
return;
|
||||||
@@ -85,6 +77,7 @@ export default function PortalPage() {
|
|||||||
password: "",
|
password: "",
|
||||||
role: "CUSTOMER",
|
role: "CUSTOMER",
|
||||||
});
|
});
|
||||||
|
setShowUserValidation(false);
|
||||||
setMessage("Benutzer gespeichert.");
|
setMessage("Benutzer gespeichert.");
|
||||||
await loadSnapshot();
|
await loadSnapshot();
|
||||||
} catch (userError) {
|
} catch (userError) {
|
||||||
@@ -114,15 +107,6 @@ export default function PortalPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (!snapshot) {
|
||||||
return <div className="empty-state">Portal wird geladen ...</div>;
|
return <div className="empty-state">Portal wird geladen ...</div>;
|
||||||
}
|
}
|
||||||
@@ -181,21 +165,23 @@ export default function PortalPage() {
|
|||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<article className="section-card">
|
<article className="section-card">
|
||||||
<p className="eyebrow">Benutzerverwaltung</p>
|
<p className="eyebrow">Benutzerverwaltung</p>
|
||||||
<form className="field-grid" onSubmit={handleCreateUser}>
|
<form className={`field-grid ${showUserValidation ? "show-validation" : ""}`} onSubmit={handleCreateUser}>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Kuerzel</span>
|
<span>Kuerzel</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.code}
|
value={userForm.code}
|
||||||
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
|
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<input
|
<input
|
||||||
value={userForm.displayName}
|
value={userForm.displayName}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setUserForm((current) => ({ ...current, displayName: event.target.value }))
|
setUserForm((current) => ({ ...current, displayName: event.target.value }))
|
||||||
}
|
}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -288,68 +274,6 @@ export default function PortalPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function SampleRegistrationPage() {
|
|||||||
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);
|
||||||
|
const [showValidation, setShowValidation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -79,6 +80,7 @@ export default function SampleRegistrationPage() {
|
|||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setShowValidation(true);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -118,7 +120,7 @@ export default function SampleRegistrationPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="page-stack" onSubmit={handleSubmit}>
|
<form className={`page-stack ${showValidation ? "show-validation" : ""}`} onSubmit={handleSubmit}>
|
||||||
<section className="section-card section-card--hero">
|
<section className="section-card section-card--hero">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Neuanlage</p>
|
<p className="eyebrow">Neuanlage</p>
|
||||||
@@ -141,13 +143,14 @@ export default function SampleRegistrationPage() {
|
|||||||
<section className="form-grid form-grid--stacked">
|
<section className="form-grid form-grid--stacked">
|
||||||
<article className="section-card">
|
<article className="section-card">
|
||||||
<p className="eyebrow">Stammdaten</p>
|
<p className="eyebrow">Stammdaten</p>
|
||||||
<div className="field-grid">
|
<div className="field-grid field-grid--stacked">
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Landwirt</span>
|
<span>Landwirt</span>
|
||||||
<select
|
<select
|
||||||
value={farmerBusinessKey}
|
value={farmerBusinessKey}
|
||||||
onChange={(event) => setFarmerBusinessKey(event.target.value)}
|
onChange={(event) => setFarmerBusinessKey(event.target.value)}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
{catalogs?.farmers.map((farmer) => (
|
{catalogs?.farmers.map((farmer) => (
|
||||||
<option key={farmer.businessKey} value={farmer.businessKey}>
|
<option key={farmer.businessKey} value={farmer.businessKey}>
|
||||||
@@ -157,12 +160,13 @@ export default function SampleRegistrationPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field field--required">
|
||||||
<span>Kuh-Nummer</span>
|
<span>Kuh-Nummer</span>
|
||||||
<input
|
<input
|
||||||
value={cowNumber}
|
value={cowNumber}
|
||||||
onChange={(event) => setCowNumber(event.target.value)}
|
onChange={(event) => setCowNumber(event.target.value)}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
90
frontend/src/pages/SearchCalendarPage.tsx
Normal file
90
frontend/src/pages/SearchCalendarPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import SampleSearchResultsSection from "../components/SampleSearchResultsSection";
|
||||||
|
import { apiGet } from "../lib/api";
|
||||||
|
import type { LookupResult, PortalSampleRow } from "../lib/types";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function routeForLookup(result: LookupResult) {
|
||||||
|
return result.sampleId && result.routeSegment ? `/samples/${result.sampleId}/${result.routeSegment}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchCalendarPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [selectedDate, setSelectedDate] = useState("");
|
||||||
|
const [samples, setSamples] = useState<PortalSampleRow[]>([]);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [resultLabel, setResultLabel] = useState("Bitte Datum auswaehlen");
|
||||||
|
|
||||||
|
async function handleDateChange(nextDate: string) {
|
||||||
|
setSelectedDate(nextDate);
|
||||||
|
if (!nextDate) {
|
||||||
|
setSamples([]);
|
||||||
|
setMessage(null);
|
||||||
|
setResultLabel("Bitte Datum auswaehlen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await apiGet<PortalSampleRow[]>(
|
||||||
|
`/portal/search/by-date?date=${encodeURIComponent(nextDate)}`,
|
||||||
|
);
|
||||||
|
setSamples(rows);
|
||||||
|
setResultLabel(`Erfasste Proben am ${nextDate.split("-").reverse().join(".")}`);
|
||||||
|
setMessage(rows.length ? null : "An diesem Tag wurden keine Proben erfasst.");
|
||||||
|
} catch (dateError) {
|
||||||
|
setSamples([]);
|
||||||
|
setResultLabel("Keine Treffer");
|
||||||
|
setMessage((dateError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSample(sampleNumber: number) {
|
||||||
|
try {
|
||||||
|
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber}`);
|
||||||
|
const target = routeForLookup(result);
|
||||||
|
if (!result.found || !target) {
|
||||||
|
setMessage(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(target);
|
||||||
|
} catch (openError) {
|
||||||
|
setMessage((openError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Suche | Kalendar</p>
|
||||||
|
<h3>Proben nach Erfassungsdatum finden</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Waehle einen Tag im Kalendar aus, um alle an diesem Datum erfassten Proben in der Liste zu sehen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-card">
|
||||||
|
<label className="field field--required">
|
||||||
|
<span>Kalendar</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(event) => void handleDateChange(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SampleSearchResultsSection
|
||||||
|
eyebrow="Suchergebnisse"
|
||||||
|
title={resultLabel}
|
||||||
|
emptyText="Bitte ein Datum im Kalendar auswaehlen."
|
||||||
|
samples={samples}
|
||||||
|
onOpen={(sampleNumber) => void openSample(sampleNumber)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
frontend/src/pages/SearchFarmerPage.tsx
Normal file
141
frontend/src/pages/SearchFarmerPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import SampleSearchResultsSection from "../components/SampleSearchResultsSection";
|
||||||
|
import { apiGet } from "../lib/api";
|
||||||
|
import type { FarmerOption, LookupResult, PortalSampleRow, PortalSnapshot } from "../lib/types";
|
||||||
|
|
||||||
|
function routeForLookup(result: LookupResult) {
|
||||||
|
return result.sampleId && result.routeSegment ? `/samples/${result.sampleId}/${result.routeSegment}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchFarmerPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [farmerQuery, setFarmerQuery] = useState("");
|
||||||
|
const [farmers, setFarmers] = useState<FarmerOption[]>([]);
|
||||||
|
const [samples, setSamples] = useState<PortalSampleRow[]>([]);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [showValidation, setShowValidation] = useState(false);
|
||||||
|
const [resultLabel, setResultLabel] = useState("Bitte Landwirt suchen");
|
||||||
|
|
||||||
|
async function loadFarmerSamples(farmer: FarmerOption) {
|
||||||
|
const response = await apiGet<PortalSnapshot>(
|
||||||
|
`/portal/snapshot?farmerBusinessKey=${encodeURIComponent(farmer.businessKey)}`,
|
||||||
|
);
|
||||||
|
setSamples(response.samples);
|
||||||
|
setResultLabel(`Proben von ${farmer.name}`);
|
||||||
|
setMessage(response.samples.length ? null : "Zu diesem Landwirt wurden noch keine Proben gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setShowValidation(true);
|
||||||
|
if (!farmerQuery.trim()) {
|
||||||
|
setMessage("Bitte einen Landwirt eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiGet<PortalSnapshot>(
|
||||||
|
`/portal/snapshot?farmerQuery=${encodeURIComponent(farmerQuery.trim())}`,
|
||||||
|
);
|
||||||
|
setFarmers(response.farmers);
|
||||||
|
if (!response.farmers.length) {
|
||||||
|
setSamples([]);
|
||||||
|
setResultLabel("Keine Treffer");
|
||||||
|
setMessage("Kein passender Landwirt gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.farmers.length === 1) {
|
||||||
|
await loadFarmerSamples(response.farmers[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSamples([]);
|
||||||
|
setResultLabel("Landwirt auswaehlen");
|
||||||
|
setMessage(null);
|
||||||
|
} catch (searchError) {
|
||||||
|
setFarmers([]);
|
||||||
|
setSamples([]);
|
||||||
|
setResultLabel("Keine Treffer");
|
||||||
|
setMessage((searchError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSample(sampleNumber: number) {
|
||||||
|
try {
|
||||||
|
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber}`);
|
||||||
|
const target = routeForLookup(result);
|
||||||
|
if (!result.found || !target) {
|
||||||
|
setMessage(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(target);
|
||||||
|
} catch (openError) {
|
||||||
|
setMessage((openError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Suche | Landwirt</p>
|
||||||
|
<h3>Proben nach Landwirt finden</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Suche nach dem Landwirt und oeffne danach eine der zugehoerigen Proben direkt aus der Trefferliste.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-card">
|
||||||
|
<form className={`hero-card__form ${showValidation ? "show-validation" : ""}`} onSubmit={handleSearch}>
|
||||||
|
<label className="field field--required">
|
||||||
|
<span>Landwirt</span>
|
||||||
|
<input
|
||||||
|
value={farmerQuery}
|
||||||
|
onChange={(event) => setFarmerQuery(event.target.value)}
|
||||||
|
placeholder="z. B. Agrar Lindenblick"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="accent-button">
|
||||||
|
Landwirt suchen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{farmers.length > 1 ? (
|
||||||
|
<section className="section-card">
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Treffer</p>
|
||||||
|
<h3>Gefundene Landwirte</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="user-grid">
|
||||||
|
{farmers.map((farmer) => (
|
||||||
|
<button
|
||||||
|
key={farmer.businessKey}
|
||||||
|
type="button"
|
||||||
|
className="user-card"
|
||||||
|
onClick={() => void loadFarmerSamples(farmer)}
|
||||||
|
>
|
||||||
|
<strong>{farmer.name}</strong>
|
||||||
|
<small>{farmer.email ?? "ohne E-Mail"}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SampleSearchResultsSection
|
||||||
|
eyebrow="Suchergebnisse"
|
||||||
|
title={resultLabel}
|
||||||
|
emptyText="Bitte zuerst einen Landwirt suchen oder aus der Trefferliste auswaehlen."
|
||||||
|
samples={samples}
|
||||||
|
onOpen={(sampleNumber) => void openSample(sampleNumber)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/pages/SearchPage.tsx
Normal file
119
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import SampleSearchResultsSection from "../components/SampleSearchResultsSection";
|
||||||
|
import { apiGet } from "../lib/api";
|
||||||
|
import type { LookupResult, PortalSampleRow, SampleDetail } from "../lib/types";
|
||||||
|
|
||||||
|
function routeForLookup(result: LookupResult) {
|
||||||
|
return result.sampleId && result.routeSegment ? `/samples/${result.sampleId}/${result.routeSegment}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPortalRow(sample: SampleDetail): PortalSampleRow {
|
||||||
|
return {
|
||||||
|
sampleId: sample.id,
|
||||||
|
sampleNumber: sample.sampleNumber,
|
||||||
|
createdAt: sample.createdAt,
|
||||||
|
completedAt: sample.completedAt,
|
||||||
|
farmerBusinessKey: sample.farmerBusinessKey,
|
||||||
|
farmerName: sample.farmerName,
|
||||||
|
farmerEmail: sample.farmerEmail,
|
||||||
|
cowNumber: sample.cowNumber,
|
||||||
|
cowName: sample.cowName,
|
||||||
|
sampleKindLabel: sample.sampleKind,
|
||||||
|
internalNote: sample.therapy?.internalNote ?? null,
|
||||||
|
completed: sample.completed,
|
||||||
|
reportSent: sample.reportSent,
|
||||||
|
reportBlocked: sample.reportBlocked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sampleNumber, setSampleNumber] = useState("");
|
||||||
|
const [samples, setSamples] = useState<PortalSampleRow[]>([]);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [showValidation, setShowValidation] = useState(false);
|
||||||
|
const [resultLabel, setResultLabel] = useState("Bitte Probennummer eingeben");
|
||||||
|
|
||||||
|
async function handleSearchByNumber(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setShowValidation(true);
|
||||||
|
if (!sampleNumber.trim()) {
|
||||||
|
setMessage("Bitte eine Probennummer eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumber.trim()}`);
|
||||||
|
if (!result.found || !result.sampleId) {
|
||||||
|
setMessage(result.message);
|
||||||
|
setSamples([]);
|
||||||
|
setResultLabel("Keine Treffer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sample = await apiGet<SampleDetail>(`/samples/by-number/${sampleNumber.trim()}`);
|
||||||
|
setSamples([toPortalRow(sample)]);
|
||||||
|
setResultLabel(`Suchtreffer fuer Probe ${sample.sampleNumber}`);
|
||||||
|
setMessage(null);
|
||||||
|
} catch (searchError) {
|
||||||
|
setSamples([]);
|
||||||
|
setMessage((searchError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSample(sampleNumberToOpen: number) {
|
||||||
|
try {
|
||||||
|
const result = await apiGet<LookupResult>(`/dashboard/lookup/${sampleNumberToOpen}`);
|
||||||
|
const target = routeForLookup(result);
|
||||||
|
if (!result.found || !target) {
|
||||||
|
setMessage(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(target);
|
||||||
|
} catch (openError) {
|
||||||
|
setMessage((openError as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="section-card section-card--hero">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Suche | Probe</p>
|
||||||
|
<h3>Probe per Nummer finden</h3>
|
||||||
|
<p className="muted-text">
|
||||||
|
Suche gezielt nach einer Probennummer und oeffne den passenden Arbeitsschritt direkt aus der Trefferliste.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? <div className="alert alert--error">{message}</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-card">
|
||||||
|
<form className={`hero-card__form ${showValidation ? "show-validation" : ""}`} onSubmit={handleSearchByNumber}>
|
||||||
|
<label className="field field--required">
|
||||||
|
<span>Probennummer</span>
|
||||||
|
<input
|
||||||
|
value={sampleNumber}
|
||||||
|
onChange={(event) => setSampleNumber(event.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="z. B. 100203"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="accent-button">
|
||||||
|
Probe suchen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SampleSearchResultsSection
|
||||||
|
eyebrow="Suchergebnisse"
|
||||||
|
title={resultLabel}
|
||||||
|
emptyText="Bitte eine Probennummer eingeben und die Suche starten."
|
||||||
|
samples={samples}
|
||||||
|
onOpen={(sampleNumberValue) => void openSample(sampleNumberValue)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -118,6 +118,33 @@ a {
|
|||||||
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group__label {
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
color: rgba(248, 243, 237, 0.5);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-subnav {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-sublink {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: rgba(248, 243, 237, 0.72);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link:hover,
|
.nav-link:hover,
|
||||||
.nav-link.is-active {
|
.nav-link.is-active {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
@@ -125,6 +152,13 @@ a {
|
|||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-sublink:hover,
|
||||||
|
.nav-sublink.is-active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff8f0;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar__footer {
|
.sidebar__footer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -319,6 +353,10 @@ a {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-grid--stacked {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -333,6 +371,18 @@ a {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field--required span::after,
|
||||||
|
.required-label::after {
|
||||||
|
content: " *";
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-label {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
.field select,
|
.field select,
|
||||||
.field textarea,
|
.field textarea,
|
||||||
@@ -351,6 +401,21 @@ a {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.show-validation .field input:invalid,
|
||||||
|
.show-validation .field select:invalid,
|
||||||
|
.show-validation .field textarea:invalid,
|
||||||
|
.field input.is-invalid,
|
||||||
|
.field select.is-invalid,
|
||||||
|
.field textarea.is-invalid,
|
||||||
|
.data-table input.is-invalid,
|
||||||
|
.data-table select.is-invalid,
|
||||||
|
.pathogen-grid.is-invalid,
|
||||||
|
.choice-row.is-invalid,
|
||||||
|
.tab-chip.is-invalid {
|
||||||
|
border-color: rgba(157, 60, 48, 0.55);
|
||||||
|
box-shadow: 0 0 0 3px rgba(157, 60, 48, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.choice-row,
|
.choice-row,
|
||||||
.tab-row,
|
.tab-row,
|
||||||
.page-actions,
|
.page-actions,
|
||||||
@@ -385,6 +450,10 @@ a {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32);
|
box-shadow: inset 0 0 0 1px rgba(17, 109, 99, 0.32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-chip.is-invalid {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.section-card__header {
|
.section-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -414,6 +483,12 @@ a {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pathogen-grid.is-invalid {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(157, 60, 48, 0.3);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.user-grid {
|
.user-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
|
|||||||
Reference in New Issue
Block a user