feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren - Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze - Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix) - Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt - Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand) - Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten - Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck - Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
This commit is contained in:
1 parent
0a52b29c57
commit
8432313fa9
83 files changed
+2684
-289
No files matched your search
@@ -69,7 +69,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
|
||||
@@ -40,28 +40,34 @@ export default function ApiaryDetailPage() {
|
||||
<div className="flex justify-end">
|
||||
<QrCodeDialog entityType="apiaries" id={apiary.id} />
|
||||
</div>
|
||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||
<WeatherCard
|
||||
apiaryId={apiary.id}
|
||||
hasCoordinates={apiary.latitude !== null && apiary.longitude !== null}
|
||||
/>
|
||||
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
|
||||
>
|
||||
Neue Ernte
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={exportReportMutation.isPending}
|
||||
onClick={() => exportReportMutation.mutate()}
|
||||
>
|
||||
{exportReportMutation.isPending
|
||||
? "Wird erstellt…"
|
||||
: `Jahresbericht ${CURRENT_YEAR}`}
|
||||
</Button>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
|
||||
>
|
||||
Neue Ernte
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={exportReportMutation.isPending}
|
||||
onClick={() => exportReportMutation.mutate()}
|
||||
>
|
||||
{exportReportMutation.isPending
|
||||
? "Wird erstellt…"
|
||||
: `Jahresbericht ${CURRENT_YEAR}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<WeatherCard
|
||||
apiaryId={apiary.id}
|
||||
hasCoordinates={apiary.latitude !== null && apiary.longitude !== null}
|
||||
/>
|
||||
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fetchBottlingBatch } from "@/lib/bottling/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { BottlingEditDialog } from "./bottling-edit-dialog";
|
||||
import { PrintLabelsCard } from "./print-labels-card";
|
||||
|
||||
export default function BottlingBatchDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -29,50 +30,53 @@ export default function BottlingBatchDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<Package className="size-4" />
|
||||
</span>
|
||||
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BottlingEditDialog bottlingBatch={bottlingBatch} />
|
||||
<QrCodeDialog
|
||||
entityType="bottling-batches"
|
||||
id={bottlingBatch.id}
|
||||
label="QR-Code für Etikett"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
<p>Honigcharge: {bottlingBatch.honeyBatch.batchNumber}</p>
|
||||
{bottlingBatch.product ? <p>Produkt: {bottlingBatch.product.name}</p> : null}
|
||||
<p>Datum: {new Date(bottlingBatch.date).toLocaleDateString("de-DE")}</p>
|
||||
<p>
|
||||
{bottlingBatch.jarCount} × {bottlingBatch.jarSize} g ={" "}
|
||||
{bottlingBatch.totalAmount} kg
|
||||
</p>
|
||||
<p>
|
||||
Mindesthaltbar bis:{" "}
|
||||
{new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
<p>
|
||||
Öffentliche Herkunftsseite:{" "}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`}
|
||||
target="_blank"
|
||||
>
|
||||
/herkunft/{bottlingBatch.lotNumber}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<Package className="size-4" />
|
||||
</span>
|
||||
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BottlingEditDialog bottlingBatch={bottlingBatch} />
|
||||
<QrCodeDialog
|
||||
entityType="bottling-batches"
|
||||
id={bottlingBatch.id}
|
||||
label="QR-Code für Etikett"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
<p>Honigcharge: {bottlingBatch.honeyBatch.batchNumber}</p>
|
||||
{bottlingBatch.product ? <p>Produkt: {bottlingBatch.product.name}</p> : null}
|
||||
<p>Datum: {new Date(bottlingBatch.date).toLocaleDateString("de-DE")}</p>
|
||||
<p>
|
||||
{bottlingBatch.jarCount} × {bottlingBatch.jarSize} g ={" "}
|
||||
{bottlingBatch.totalAmount} kg
|
||||
</p>
|
||||
<p>
|
||||
Mindesthaltbar bis:{" "}
|
||||
{new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
<p>
|
||||
Öffentliche Herkunftsseite:{" "}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`}
|
||||
target="_blank"
|
||||
>
|
||||
/herkunft/{bottlingBatch.lotNumber}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PrintLabelsCard bottlingBatchId={bottlingBatch.id} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Printer, Tag } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) {
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ["label-templates"],
|
||||
queryFn: fetchLabelTemplates,
|
||||
});
|
||||
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||
const preferredTemplateId =
|
||||
activeTemplates.find((template) => template.isDefault)?.id ?? activeTemplates[0]?.id ?? "";
|
||||
const effectiveTemplateId = templateId || preferredTemplateId;
|
||||
const selectedTemplate = activeTemplates.find(
|
||||
(template) => template.id === effectiveTemplateId,
|
||||
);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId),
|
||||
onError: () => toast.error("Etiketten konnten nicht erstellt werden"),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tag className="size-4.5" />
|
||||
Etiketten drucken
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{activeTemplates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch keine Etikettenvorlage angelegt. Unter Einstellungen → Etikettenvorlagen eine
|
||||
Vorlage erstellen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
value={effectiveTemplateId}
|
||||
onValueChange={(value) => setTemplateId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Vorlage wählen">{selectedTemplate?.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeTemplates.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!effectiveTemplateId || mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
{mutation.isPending ? "Wird erstellt…" : "Etiketten als PDF"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -157,7 +157,7 @@ function SplitDialog({ colony }: { colony: Colony }) {
|
||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||
>
|
||||
<SelectTrigger id="split-type">
|
||||
<SelectValue />
|
||||
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLONY_TYPES.map((type) => (
|
||||
|
||||
@@ -113,7 +113,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
@@ -135,7 +135,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||
>
|
||||
<SelectTrigger id="colony-type">
|
||||
<SelectValue />
|
||||
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLONY_TYPES.map((type) => (
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Fütterung</CardTitle>
|
||||
<Button
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ColonyHistoryCard({ colonyId }: { colonyId: string }) {
|
||||
history.lineageAsResult.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historie</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Durchsichten</CardTitle>
|
||||
<Button
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Schwarmkontrolle</CardTitle>
|
||||
<Button
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Behandlungen</CardTitle>
|
||||
<Button
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
||||
.filter((value): value is number => value !== null);
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Varroa</CardTitle>
|
||||
<Button
|
||||
|
||||
@@ -5,6 +5,12 @@ import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateInspection } from "@/lib/inspections/api";
|
||||
import { Inspection } from "@/lib/inspections/types";
|
||||
|
||||
const FEED_STATUS_LABELS: Record<string, string> = {
|
||||
ausreichend: "Ausreichend",
|
||||
niedrig: "Niedrig",
|
||||
kritisch: "Kritisch",
|
||||
};
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -125,7 +131,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
||||
onValueChange={(value) => setFeedStatus((value ?? "ausreichend") as string)}
|
||||
>
|
||||
<SelectTrigger id="inspection-edit-feed" className="w-full">
|
||||
<SelectValue />
|
||||
<SelectValue>{FEED_STATUS_LABELS[feedStatus] ?? feedStatus}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ausreichend">Ausreichend</SelectItem>
|
||||
|
||||
@@ -54,15 +54,23 @@ export default function ColonyDetailPage() {
|
||||
</Button>
|
||||
<QrCodeDialog entityType="colonies" id={colony.id} />
|
||||
</div>
|
||||
<ColonyEditCard key={colony.id} colony={colony} />
|
||||
<ColonyActions colony={colony} />
|
||||
<ColonyInspectionsCard colonyId={colony.id} />
|
||||
<ColonyVarroaCard colonyId={colony.id} />
|
||||
<ColonySwarmControlCard colonyId={colony.id} />
|
||||
<ColonyTreatmentsCard colonyId={colony.id} />
|
||||
<ColonyFeedingsCard colonyId={colony.id} />
|
||||
<ColonyHistoryCard colonyId={colony.id} />
|
||||
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||
<ColonyEditCard key={colony.id} colony={colony} />
|
||||
<ColonyActions colony={colony} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<ColonyInspectionsCard colonyId={colony.id} />
|
||||
<ColonyVarroaCard colonyId={colony.id} />
|
||||
<ColonySwarmControlCard colonyId={colony.id} />
|
||||
<ColonyTreatmentsCard colonyId={colony.id} />
|
||||
<ColonyFeedingsCard colonyId={colony.id} />
|
||||
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
|
||||
</div>
|
||||
<ColonyHistoryCard colonyId={colony.id} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export default function NewColonyPage() {
|
||||
onValueChange={(value) => setColonyType(value as ColonyType)}
|
||||
>
|
||||
<SelectTrigger id="colony-type">
|
||||
<SelectValue />
|
||||
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLONY_TYPES.map((type) => (
|
||||
|
||||
@@ -73,7 +73,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Lagerbehälter</CardTitle>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -46,116 +46,126 @@ export default function HoneyBatchDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<Droplet className="size-4" />
|
||||
</span>
|
||||
<CardTitle className="font-mono">{honeyBatch.batchNumber}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}</Badge>
|
||||
<HoneyBatchEditDialog honeyBatch={honeyBatch} />
|
||||
<QrCodeDialog entityType="honey-batches" id={honeyBatch.id} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null}
|
||||
{honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null}
|
||||
{honeyBatch.waterContent !== null ? (
|
||||
<p>Wassergehalt: {honeyBatch.waterContent}%</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/bottling/new?honeyBatchId=${honeyBatch.id}`} />}
|
||||
>
|
||||
Neue Abfüllung
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Honigmessungen</CardTitle>
|
||||
<AddMeasurementDialog honeyBatchId={honeyBatch.id} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{honeyBatch.measurements.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FlaskConical}
|
||||
title="Noch keine Messung"
|
||||
description="Erfasse die erste Wassergehalt-Messung für diese Charge."
|
||||
/>
|
||||
) : null}
|
||||
{honeyBatch.measurements.map((measurement) => (
|
||||
<div key={measurement.id} className="rounded-md border p-3">
|
||||
<p className="font-medium">
|
||||
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
|
||||
{measurement.waterContent}% Wassergehalt
|
||||
</p>
|
||||
{measurement.device ? <p className="text-muted-foreground">{measurement.device}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Rückverfolgbarkeit</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportPdfMutation.isPending}
|
||||
onClick={() => exportPdfMutation.mutate()}
|
||||
>
|
||||
PDF-Bericht
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{!traceability ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||
{traceability ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>
|
||||
Schleuderung am{" "}
|
||||
{new Date(traceability.extraction.date).toLocaleDateString("de-DE")}
|
||||
{traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""}
|
||||
</p>
|
||||
<ExtractionEditDialog
|
||||
extractionId={traceability.extraction.id}
|
||||
honeyBatchId={honeyBatch.id}
|
||||
/>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<Droplet className="size-4" />
|
||||
</span>
|
||||
<CardTitle className="font-mono">{honeyBatch.batchNumber}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{traceability.harvests.map((harvest) => (
|
||||
<Link
|
||||
key={harvest.id}
|
||||
href={`/harvests/${harvest.id}`}
|
||||
className="rounded-md border p-3 hover:bg-muted"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}</Badge>
|
||||
<HoneyBatchEditDialog honeyBatch={honeyBatch} />
|
||||
<QrCodeDialog entityType="honey-batches" id={honeyBatch.id} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null}
|
||||
{honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null}
|
||||
{honeyBatch.waterContent !== null ? (
|
||||
<p>Wassergehalt: {honeyBatch.waterContent}%</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/bottling/new?honeyBatchId=${honeyBatch.id}`} />}
|
||||
>
|
||||
Neue Abfüllung
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Honigmessungen</CardTitle>
|
||||
<AddMeasurementDialog honeyBatchId={honeyBatch.id} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{honeyBatch.measurements.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FlaskConical}
|
||||
title="Noch keine Messung"
|
||||
description="Erfasse die erste Wassergehalt-Messung für diese Charge."
|
||||
/>
|
||||
) : null}
|
||||
{honeyBatch.measurements.map((measurement) => (
|
||||
<div key={measurement.id} className="rounded-md border p-3">
|
||||
<p className="font-medium">
|
||||
Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||
{new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "}
|
||||
{measurement.waterContent}% Wassergehalt
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Völker: {harvest.colonies.map((colony) => colony.code).join(", ")}
|
||||
</p>
|
||||
</Link>
|
||||
{measurement.device ? (
|
||||
<p className="text-muted-foreground">{measurement.device}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
|
||||
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
|
||||
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Rückverfolgbarkeit</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportPdfMutation.isPending}
|
||||
onClick={() => exportPdfMutation.mutate()}
|
||||
>
|
||||
PDF-Bericht
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{!traceability ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||
{traceability ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p>
|
||||
Schleuderung am{" "}
|
||||
{new Date(traceability.extraction.date).toLocaleDateString("de-DE")}
|
||||
{traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""}
|
||||
</p>
|
||||
<ExtractionEditDialog
|
||||
extractionId={traceability.extraction.id}
|
||||
honeyBatchId={honeyBatch.id}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{traceability.harvests.map((harvest) => (
|
||||
<Link
|
||||
key={harvest.id}
|
||||
href={`/harvests/${harvest.id}`}
|
||||
className="rounded-md border p-3 hover:bg-muted"
|
||||
>
|
||||
<p className="font-medium">
|
||||
Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Völker: {harvest.colonies.map((colony) => colony.code).join(", ")}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
const FEED_STATUS_LABELS = {
|
||||
ausreichend: "Ausreichend",
|
||||
niedrig: "Niedrig",
|
||||
kritisch: "Kritisch",
|
||||
} as const;
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -191,7 +197,7 @@ export default function NewInspectionPage() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="inspection-feed-status" className="h-11 w-full">
|
||||
<SelectValue />
|
||||
<SelectValue>{FEED_STATUS_LABELS[feedStatus]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ausreichend">Ausreichend</SelectItem>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { deactivateAccount } from "@/lib/profile/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function DeactivateAccountDialog() {
|
||||
const router = useRouter();
|
||||
const { logout } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deactivateAccount(currentPassword),
|
||||
onSuccess: async () => {
|
||||
toast.success("Konto deaktiviert");
|
||||
await logout();
|
||||
router.push("/login");
|
||||
},
|
||||
onError: () => setError("Passwort ist falsch oder das Konto kann nicht deaktiviert werden."),
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
setOpen(false);
|
||||
setCurrentPassword("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
|
||||
<Button type="button" variant="destructive" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
Konto löschen
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Konto löschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Dein Konto wird deaktiviert und du wirst abgemeldet. Die Historie deiner bisherigen
|
||||
Einträge bleibt erhalten. Zur Bestätigung aktuelles Passwort eingeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="deactivate-account-password">Passwort</Label>
|
||||
<Input
|
||||
id="deactivate-account-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird deaktiviert…" : "Konto löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DangerZoneSection() {
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Konto löschen</CardTitle>
|
||||
<CardDescription>
|
||||
Unwiderruflich für dich: Danach kannst du dich nicht mehr anmelden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DeactivateAccountDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import type { ReactElement } from "react";
|
||||
import ProfilePage from "./page";
|
||||
import { updateProfile, changePassword, uploadAvatar } from "@/lib/profile/api";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/profile/api", () => ({
|
||||
updateProfile: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
@@ -15,6 +19,7 @@ vi.mock("@/lib/profile/api", () => ({
|
||||
enrollMfa: vi.fn(),
|
||||
confirmMfaEnrollment: vi.fn(),
|
||||
disableMfa: vi.fn(),
|
||||
deactivateAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
const refreshUserMock = vi.fn();
|
||||
@@ -29,7 +34,7 @@ const mockUser = {
|
||||
hasAvatar: false,
|
||||
};
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock }),
|
||||
useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock, logout: vi.fn() }),
|
||||
}));
|
||||
|
||||
function renderWithClient(ui: ReactElement) {
|
||||
@@ -53,13 +58,13 @@ describe("ProfilePage", () => {
|
||||
});
|
||||
renderWithClient(<ProfilePage />);
|
||||
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "New Name");
|
||||
const lastNameInput = screen.getByLabelText("Nachname");
|
||||
await userEvent.clear(lastNameInput);
|
||||
await userEvent.type(lastNameInput, "Name");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(updateProfile).toHaveBeenCalledWith({
|
||||
name: "New Name",
|
||||
name: "Existing Name",
|
||||
email: "[email protected]",
|
||||
});
|
||||
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/profile/api";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -24,6 +25,12 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { MfaSection } from "./mfa-section";
|
||||
import { DangerZoneSection } from "./danger-zone-section";
|
||||
|
||||
function splitName(fullName: string): { firstName: string; lastName: string } {
|
||||
const [firstName, ...rest] = fullName.trim().split(/\s+/);
|
||||
return { firstName: firstName ?? "", lastName: rest.join(" ") };
|
||||
}
|
||||
|
||||
function AvatarSection() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
@@ -100,14 +107,21 @@ function AvatarSection() {
|
||||
|
||||
function ProfileInfoSection() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [name, setName] = useState(user?.name ?? "");
|
||||
const initialName = splitName(user?.name ?? "");
|
||||
const [firstName, setFirstName] = useState(initialName.firstName);
|
||||
const [lastName, setLastName] = useState(initialName.lastName);
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const emailChanged = email !== user?.email;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateProfile({ name, email, currentPassword: currentPassword || undefined }),
|
||||
mutationFn: () =>
|
||||
updateProfile({
|
||||
name: [firstName, lastName].filter(Boolean).join(" "),
|
||||
email,
|
||||
currentPassword: currentPassword || undefined,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setCurrentPassword("");
|
||||
await refreshUser();
|
||||
@@ -131,18 +145,31 @@ function ProfileInfoSection() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profil</CardTitle>
|
||||
<CardDescription>Name und E-Mail-Adresse</CardDescription>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
Name, E-Mail-Adresse und Rolle
|
||||
{user ? <Badge variant="secondary">{user.roleName}</Badge> : null}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="profile-name">Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="profile-first-name">Vorname</Label>
|
||||
<Input
|
||||
id="profile-first-name"
|
||||
required
|
||||
value={firstName}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="profile-last-name">Nachname</Label>
|
||||
<Input
|
||||
id="profile-last-name"
|
||||
value={lastName}
|
||||
onChange={(event) => setLastName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="profile-email">E-Mail</Label>
|
||||
@@ -270,6 +297,7 @@ export default function ProfilePage() {
|
||||
<ProfileInfoSection />
|
||||
<PasswordSection />
|
||||
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
||||
<DangerZoneSection />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -34,12 +34,20 @@ export default function QueenDetailPage() {
|
||||
<div className="flex justify-end">
|
||||
<QrCodeDialog entityType="queens" id={queen.id} />
|
||||
</div>
|
||||
<QueenEditCard key={queen.id} queen={queen} />
|
||||
<AssignColonyDialog queenId={queen.id} />
|
||||
<QueenEvaluationsCard queenId={queen.id} />
|
||||
<QueenLineageCard queenId={queen.id} />
|
||||
<QueenHistoryCard queenId={queen.id} />
|
||||
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||
<QueenEditCard key={queen.id} queen={queen} />
|
||||
<AssignColonyDialog queenId={queen.id} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<QueenEvaluationsCard queenId={queen.id} />
|
||||
<QueenLineageCard queenId={queen.id} />
|
||||
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
|
||||
</div>
|
||||
<QueenHistoryCard queenId={queen.id} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
|
||||
@@ -78,7 +78,7 @@ export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Bewertung</CardTitle>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function QueenHistoryCard({ queenId }: { queenId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Volk-Historie</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function QueenLineageCard({ queenId }: { queenId: string }) {
|
||||
const hasLineage = ancestors.length > 0 || (lineage?.descendants.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitBranch className="size-4" />
|
||||
|
||||
@@ -14,18 +14,27 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
|
||||
QUEENLESS_NO_EGGS: "Keine Königin gesehen & keine Stifte",
|
||||
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte",
|
||||
VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten",
|
||||
FEED_STATUS_CRITICAL: "Futterstatus kritisch",
|
||||
SWARM_MOOD_HIGH: "Hohe Schwarmstimmung",
|
||||
COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch",
|
||||
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand",
|
||||
};
|
||||
|
||||
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
|
||||
QUEENLESS_NO_EGGS:
|
||||
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden → Kontrollaufgabe.",
|
||||
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden, dann Kontrollaufgabe.",
|
||||
VARROA_THRESHOLD:
|
||||
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert → Behandlung prüfen.",
|
||||
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert, dann Behandlung prüfen.",
|
||||
FEED_STATUS_CRITICAL:
|
||||
"Futterstatus bei einer Durchsicht auf „kritisch“ gesetzt → Fütterungsaufgabe.",
|
||||
"Futterstatus bei einer Durchsicht auf kritisch gesetzt, dann Fütterungsaufgabe.",
|
||||
SWARM_MOOD_HIGH:
|
||||
"Bei einer Schwarmkontrolle Schwarmstimmung hoch erfasst, dann Kontrollaufgabe.",
|
||||
COLONY_HEALTH_CRITICAL:
|
||||
"Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.",
|
||||
STOCK_ITEM_LOW:
|
||||
"Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.",
|
||||
};
|
||||
|
||||
function ruleThreshold(rule: AutomationRule): string {
|
||||
@@ -59,7 +68,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{TRIGGER_LABELS[rule.triggerType]}</CardTitle>
|
||||
<Badge variant={rule.active ? "default" : "secondary"}>
|
||||
@@ -128,9 +137,11 @@ export default function AutomationRulesPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rules?.map((rule) => (
|
||||
<RuleCard key={rule.id} rule={rule} />
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{rules?.map((rule) => (
|
||||
<RuleCard key={rule.id} rule={rule} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createLabelTemplate } from "@/lib/label-templates/api";
|
||||
import { LABEL_PLACEHOLDERS } from "@/lib/label-templates/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function CreateLabelTemplateDialog() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [widthMm, setWidthMm] = useState("90");
|
||||
const [heightMm, setHeightMm] = useState("35");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setContent("");
|
||||
setWidthMm("90");
|
||||
setHeightMm("35");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createLabelTemplate,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
|
||||
setOpen(false);
|
||||
reset();
|
||||
},
|
||||
onError: () => setError("Vorlage konnte nicht angelegt werden."),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
name,
|
||||
content,
|
||||
widthMm: widthMm ? Number(widthMm) : undefined,
|
||||
heightMm: heightMm ? Number(heightMm) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setOpen(true)}>+ Neue Etikettenvorlage</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Etikettenvorlage</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="label-template-name">Name</Label>
|
||||
<Input
|
||||
id="label-template-name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="label-template-width">Breite (mm)</Label>
|
||||
<Input
|
||||
id="label-template-width"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={widthMm}
|
||||
onChange={(event) => setWidthMm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="label-template-height">Höhe (mm)</Label>
|
||||
<Input
|
||||
id="label-template-height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={heightMm}
|
||||
onChange={(event) => setHeightMm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="label-template-content">Etikettentext</Label>
|
||||
<Textarea
|
||||
id="label-template-content"
|
||||
required
|
||||
rows={6}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Platzhalter:{" "}
|
||||
{LABEL_PLACEHOLDERS.map((placeholder) => `{{${placeholder.key}}}`).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateLabelTemplate } from "@/lib/label-templates/api";
|
||||
import { LABEL_PLACEHOLDERS, LabelTemplate } from "@/lib/label-templates/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function EditLabelTemplateDialog({ template }: { template: LabelTemplate }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState(template.name);
|
||||
const [content, setContent] = useState(template.content);
|
||||
const [widthMm, setWidthMm] = useState(template.widthMm.toString());
|
||||
const [heightMm, setHeightMm] = useState(template.heightMm.toString());
|
||||
const [isDefault, setIsDefault] = useState(template.isDefault);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateLabelTemplate(template.id, {
|
||||
name,
|
||||
content,
|
||||
widthMm: widthMm ? Number(widthMm) : undefined,
|
||||
heightMm: heightMm ? Number(heightMm) : undefined,
|
||||
isDefault,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Etikettenvorlage bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="label-template-edit-name">Name</Label>
|
||||
<Input
|
||||
id="label-template-edit-name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="label-template-edit-width">Breite (mm)</Label>
|
||||
<Input
|
||||
id="label-template-edit-width"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={widthMm}
|
||||
onChange={(event) => setWidthMm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="label-template-edit-height">Höhe (mm)</Label>
|
||||
<Input
|
||||
id="label-template-edit-height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={heightMm}
|
||||
onChange={(event) => setHeightMm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="label-template-edit-content">Etikettentext</Label>
|
||||
<Textarea
|
||||
id="label-template-edit-content"
|
||||
required
|
||||
rows={6}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Platzhalter:{" "}
|
||||
{LABEL_PLACEHOLDERS.map((placeholder) => `{{${placeholder.key}}}`).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm" htmlFor="label-template-edit-default">
|
||||
<input
|
||||
id="label-template-edit-default"
|
||||
type="checkbox"
|
||||
checked={isDefault}
|
||||
onChange={(event) => setIsDefault(event.target.checked)}
|
||||
/>
|
||||
Als Standardvorlage verwenden
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { Tag } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
import { CreateLabelTemplateDialog } from "./create-label-template-dialog";
|
||||
import { EditLabelTemplateDialog } from "./edit-label-template-dialog";
|
||||
|
||||
export default function LabelTemplatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: templates, isLoading, isError } = useQuery({
|
||||
queryKey: ["label-templates"],
|
||||
queryFn: fetchLabelTemplates,
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: deactivateLabelTemplate,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
|
||||
toast.success("Vorlage deaktiviert");
|
||||
},
|
||||
onError: () => toast.error("Vorlage konnte nicht deaktiviert werden"),
|
||||
});
|
||||
|
||||
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Tag}
|
||||
title="Etikettenvorlagen"
|
||||
description="Vorlagen mit Platzhaltern für den Etikettendruck verwalten"
|
||||
gradient="var(--gradient-sky)"
|
||||
actions={<CreateLabelTemplateDialog />}
|
||||
/>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Etikettenvorlagen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{templates && activeTemplates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
title="Noch keine Etikettenvorlagen"
|
||||
description="Lege eine Vorlage an, um Etiketten für Abfüllungen zu drucken."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{activeTemplates.map((template) => (
|
||||
<EntityCard
|
||||
key={template.id}
|
||||
icon={Tag}
|
||||
gradient="var(--gradient-sky)"
|
||||
title={template.name}
|
||||
badge={template.isDefault ? <Badge>Standard</Badge> : undefined}
|
||||
fields={[
|
||||
{ label: "Größe", value: `${template.widthMm} × ${template.heightMm} mm` },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<EditLabelTemplateDialog template={template} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deactivateMutation.isPending}
|
||||
onClick={() => deactivateMutation.mutate(template.id)}
|
||||
>
|
||||
Deaktivieren
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Crown,
|
||||
Droplet,
|
||||
FileClock,
|
||||
FlaskConical,
|
||||
Hexagon,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
MapPin,
|
||||
Package,
|
||||
Repeat,
|
||||
Scissors,
|
||||
Search,
|
||||
Shield,
|
||||
ShoppingBag,
|
||||
Tag,
|
||||
Upload,
|
||||
Users,
|
||||
Warehouse,
|
||||
@@ -68,6 +71,8 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Ernte & Lager",
|
||||
items: [
|
||||
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
|
||||
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
|
||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
||||
{ label: "Abfüllungen", href: "/bottling/new", icon: Package },
|
||||
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
||||
@@ -100,6 +105,12 @@ const ADMIN_GROUP: NavGroup = {
|
||||
icon: Repeat,
|
||||
permission: "task-templates:read",
|
||||
},
|
||||
{
|
||||
label: "Etikettenvorlagen",
|
||||
href: "/settings/label-templates",
|
||||
icon: Tag,
|
||||
permission: "label-templates:read",
|
||||
},
|
||||
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,27 +4,44 @@ import userEvent from "@testing-library/user-event";
|
||||
import LoginPage from "./page";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
const replaceMock = vi.fn();
|
||||
const searchParamsMock = vi.fn(() => new URLSearchParams());
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
useRouter: () => ({ push: pushMock, replace: replaceMock }),
|
||||
useSearchParams: () => searchParamsMock(),
|
||||
}));
|
||||
|
||||
const loginMock = vi.fn();
|
||||
const verifyMfaMock = vi.fn();
|
||||
const completeSsoMock = vi.fn();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
login: loginMock,
|
||||
verifyMfa: verifyMfaMock,
|
||||
completeSso: completeSsoMock,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const ssoStatusRequestMock = vi.fn().mockResolvedValue(false);
|
||||
const ssoLoginUrlMock = vi.fn().mockResolvedValue("http://localhost:3001/api/v1/auth/sso/login");
|
||||
vi.mock("@/lib/auth/api", () => ({
|
||||
ssoStatusRequest: () => ssoStatusRequestMock(),
|
||||
ssoLoginUrl: () => ssoLoginUrlMock(),
|
||||
}));
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
replaceMock.mockReset();
|
||||
loginMock.mockReset();
|
||||
verifyMfaMock.mockReset();
|
||||
completeSsoMock.mockReset();
|
||||
searchParamsMock.mockReturnValue(new URLSearchParams());
|
||||
ssoStatusRequestMock.mockReset().mockResolvedValue(false);
|
||||
ssoLoginUrlMock.mockClear();
|
||||
});
|
||||
|
||||
it("submits credentials and redirects on success", async () => {
|
||||
@@ -83,4 +100,22 @@ describe("LoginPage", () => {
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/ungültig/i);
|
||||
});
|
||||
|
||||
it("shows the Microsoft SSO button when SSO is enabled", async () => {
|
||||
ssoStatusRequestMock.mockResolvedValue(true);
|
||||
render(<LoginPage />);
|
||||
|
||||
const link = await screen.findByRole("link", { name: /mit microsoft anmelden/i });
|
||||
expect(link).toHaveAttribute("href", "http://localhost:3001/api/v1/auth/sso/login");
|
||||
});
|
||||
|
||||
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
|
||||
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
|
||||
completeSsoMock.mockResolvedValueOnce(undefined);
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument();
|
||||
expect(completeSsoMock).toHaveBeenCalled();
|
||||
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Hexagon } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -16,15 +17,70 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
const SSO_ERROR_MESSAGES: Record<string, string> = {
|
||||
session_expired: "Sitzung abgelaufen, bitte erneut versuchen.",
|
||||
exchange_failed: "Anmeldung bei Microsoft fehlgeschlagen, bitte erneut versuchen.",
|
||||
no_email: "Das Microsoft-Konto enthält keine E-Mail-Adresse.",
|
||||
no_account:
|
||||
"Kein ApiCore-Konto mit dieser E-Mail-Adresse gefunden. Bitte an eine Administratorin oder einen Administrator wenden.",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const { login, verifyMfa } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, verifyMfa, completeSso } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [mfaToken, setMfaToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ssoPending = searchParams.get("ssoPending") === "1";
|
||||
const [error, setError] = useState<string | null>(() => {
|
||||
const ssoErrorParam = searchParams.get("ssoError");
|
||||
return ssoErrorParam
|
||||
? (SSO_ERROR_MESSAGES[ssoErrorParam] ?? "Anmeldung fehlgeschlagen, bitte erneut versuchen.")
|
||||
: null;
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||
const [ssoLoginHref, setSsoLoginHref] = useState<string | null>(null);
|
||||
const isCompletingSso = ssoPending;
|
||||
|
||||
useEffect(() => {
|
||||
ssoStatusRequest().then(setSsoEnabled);
|
||||
ssoLoginUrl().then(setSsoLoginHref);
|
||||
}, []);
|
||||
|
||||
// Cleans the one-time ssoPending/ssoError query params off the URL. The
|
||||
// error message itself was already captured into state above (from the
|
||||
// initial render), so this replace never needs to touch React state. The
|
||||
// actual sso-complete code never appears here - it travels only in an
|
||||
// httpOnly cookie the callback set, which ssoCompleteRequest() picks up.
|
||||
useEffect(() => {
|
||||
if (ssoPending) {
|
||||
completeSso()
|
||||
.then(() => router.replace("/dashboard"))
|
||||
.catch(() => {
|
||||
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
|
||||
router.replace("/login");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (searchParams.get("ssoError")) {
|
||||
router.replace("/login");
|
||||
}
|
||||
// Runs once per navigation to /login?ssoPending=1|?ssoError=...;
|
||||
// completeSso and router are stable across renders so this only needs
|
||||
// ssoPending.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ssoPending]);
|
||||
|
||||
async function handleCredentialsSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -82,7 +138,9 @@ export default function LoginPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mfaToken ? (
|
||||
{isCompletingSso ? (
|
||||
<p className="text-sm text-muted-foreground">Microsoft-Anmeldung wird abgeschlossen…</p>
|
||||
) : mfaToken ? (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleMfaSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="code">Code</Label>
|
||||
@@ -150,6 +208,20 @@ export default function LoginPage() {
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Anmelden…" : "Anmelden"}
|
||||
</Button>
|
||||
{ssoEnabled && ssoLoginHref ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
render={<a href={ssoLoginHref}>Mit Microsoft anmelden</a>}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -72,6 +72,39 @@ export function refreshRequest(): Promise<AccessTokenResponse | null> {
|
||||
return inFlightRefresh;
|
||||
}
|
||||
|
||||
export async function ssoStatusRequest(): Promise<boolean> {
|
||||
const apiUrl = await getApiUrl();
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/auth/sso/status`);
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const data = (await response.json()) as { enabled: boolean };
|
||||
return data.enabled;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ssoLoginUrl(): Promise<string> {
|
||||
const apiUrl = await getApiUrl();
|
||||
return `${apiUrl}/auth/sso/login`;
|
||||
}
|
||||
|
||||
// The one-time completion code lives only in an httpOnly cookie the callback
|
||||
// set (never in the URL or a request body) - the browser sends it automatically.
|
||||
export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
|
||||
const apiUrl = await getApiUrl();
|
||||
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
|
||||
}
|
||||
return response.json() as Promise<AccessTokenResponse>;
|
||||
}
|
||||
|
||||
export async function logoutRequest(): Promise<void> {
|
||||
const apiUrl = await getApiUrl();
|
||||
await fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
loginRequest,
|
||||
logoutRequest,
|
||||
refreshRequest,
|
||||
ssoCompleteRequest,
|
||||
verifyMfaRequest,
|
||||
} from './api';
|
||||
import { getAccessToken, setAccessToken as storeAccessToken } from './token-store';
|
||||
@@ -29,6 +30,7 @@ interface AuthContextValue {
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||
completeSso: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
refreshUser: () => Promise<void>;
|
||||
@@ -76,6 +78,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
}, []);
|
||||
|
||||
const completeSso = useCallback(async () => {
|
||||
const result = await ssoCompleteRequest();
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutRequest();
|
||||
storeAccessToken(null);
|
||||
@@ -100,7 +107,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, isLoading, login, verifyMfa, logout, hasPermission, refreshUser }}
|
||||
value={{
|
||||
user,
|
||||
isLoading,
|
||||
login,
|
||||
verifyMfa,
|
||||
completeSso,
|
||||
logout,
|
||||
hasPermission,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export type AutomationTriggerType =
|
||||
| "QUEENLESS_NO_EGGS"
|
||||
| "VARROA_THRESHOLD"
|
||||
| "FEED_STATUS_CRITICAL";
|
||||
| "FEED_STATUS_CRITICAL"
|
||||
| "SWARM_MOOD_HIGH"
|
||||
| "COLONY_HEALTH_CRITICAL"
|
||||
| "STOCK_ITEM_LOW";
|
||||
|
||||
export interface AutomationRule {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
|
||||
import { downloadBlob } from "@/lib/export/api";
|
||||
import { CreateLabelTemplateInput, LabelTemplate, UpdateLabelTemplateInput } from "./types";
|
||||
|
||||
export function fetchLabelTemplates(): Promise<LabelTemplate[]> {
|
||||
return apiFetch<LabelTemplate[]>("/label-templates");
|
||||
}
|
||||
|
||||
export function createLabelTemplate(input: CreateLabelTemplateInput): Promise<LabelTemplate> {
|
||||
return apiFetch<LabelTemplate>("/label-templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLabelTemplate(
|
||||
id: string,
|
||||
input: UpdateLabelTemplateInput,
|
||||
): Promise<LabelTemplate> {
|
||||
return apiFetch<LabelTemplate>(`/label-templates/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivateLabelTemplate(id: string): Promise<LabelTemplate> {
|
||||
return apiFetch<LabelTemplate>(`/label-templates/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function printLabels(templateId: string, bottlingBatchId: string): Promise<void> {
|
||||
const { blob, filename } = await apiFetchBlob(
|
||||
`/label-templates/${templateId}/print/${bottlingBatchId}`,
|
||||
"etiketten.pdf",
|
||||
);
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface LabelTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
isDefault: boolean;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateLabelTemplateInput {
|
||||
name: string;
|
||||
content: string;
|
||||
widthMm?: number;
|
||||
heightMm?: number;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateLabelTemplateInput {
|
||||
name?: string;
|
||||
content?: string;
|
||||
widthMm?: number;
|
||||
heightMm?: number;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export const LABEL_PLACEHOLDERS: { key: string; description: string }[] = [
|
||||
{ key: "batchNumber", description: "Chargennummer der Honigcharge" },
|
||||
{ key: "honeyType", description: "Honigsorte" },
|
||||
{ key: "lotNumber", description: "Los-/Chargennummer der Abfüllung" },
|
||||
{ key: "bottlingDate", description: "Abfülldatum" },
|
||||
{ key: "bestBeforeDate", description: "Mindesthaltbarkeitsdatum (MHD)" },
|
||||
{ key: "jarSize", description: "Glasgröße" },
|
||||
{ key: "productName", description: "Produktname" },
|
||||
{
|
||||
key: "qrCode",
|
||||
description: "QR-Code zur öffentlichen Herkunftsseite der Charge (Honigsorte, Daten, Rückverfolgbarkeit)",
|
||||
},
|
||||
];
|
||||
@@ -15,6 +15,13 @@ export function changePassword(currentPassword: string, newPassword: string): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivateAccount(currentPassword: string): Promise<void> {
|
||||
return apiFetch<void>("/profile", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadAvatar(file: File): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
Reference in new issue
Block a user