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" },
|
||||
],
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user