feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
Backend Image / build-and-push (push) Successful in 2m28s
Frontend Image / build-and-push (push) Successful in 2m10s

- 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:
Nick Szaunig committed 2026-08-31 18:56:08 +02:00
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());
+39 -11
View File
@@ -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>
);
}
@@ -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>
);
}
@@ -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");
});
});
+77 -5
View File
@@ -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>
+33
View File
@@ -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' });
+17 -1
View File
@@ -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>
+4 -1
View File
@@ -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)",
},
];
+7
View File
@@ -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);