feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
Backend Image / build-and-push (push) Successful in 2m15s
Frontend Image / build-and-push (push) Successful in 2m17s

- SiteSettings gains websiteUrl (e.g. online shop link) alongside the
  existing social links, editable in Verwaltung > Automatisierung.
- /herkunft/[lot] now shows the apiary's Standort instead of its internal
  Name (so multiple apiaries at one location read as one origin), adds
  small icons per social link, and surfaces previously-fetched-but-unused
  data (Honigsorte, Abfülldatum, stat cards) to make the page a richer
  customer-facing info page.
- Backup schedule (path/cron/retention) is now also configurable via the
  GUI (Verwaltung > Backup), stored in a new BackupSettings singleton;
  an env var always wins over the GUI value per field, and the scheduler
  reloads its cron job live when GUI settings change.
- New Automatisierung rules let an admin define a format (e.g.
  "WH-{YYYY}-{SEQ:4}") that auto-generates HoneyBatch.batchNumber and
  BottlingBatch.lotNumber on creation, with an atomic per-rule sequence
  counter and a manual reset. Manual entry still works when the rule is
  inactive.
This commit is contained in:
Nick Szaunig committed 2026-09-03 13:37:09 +02:00
1 parent 7b23862a41
commit 412d033d8d
42 files changed
+1045 -119

No files matched your search

@@ -65,7 +65,7 @@ export default function NewBottlingBatchPage() {
jarSize: Number(jarSize),
jarCount: Number(jarCount),
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
lotNumber,
lotNumber: lotNumber || undefined,
});
}
@@ -160,7 +160,7 @@ export default function NewBottlingBatchPage() {
<Label htmlFor="bottling-lot-number">Los-/Chargennummer</Label>
<Input
id="bottling-lot-number"
required
placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
@@ -61,7 +61,7 @@ export function CreateHoneyBatchDialog() {
return;
}
mutation.mutate({
batchNumber,
batchNumber: batchNumber || undefined,
extractionId,
honeyType: honeyType || undefined,
amount: amount ? Number(amount) : undefined,
@@ -80,7 +80,7 @@ export function CreateHoneyBatchDialog() {
<Label htmlFor="batch-number">Chargennummer</Label>
<Input
id="batch-number"
required
placeholder="Automatisch, falls Automatisierung aktiv"
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
@@ -3,7 +3,11 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
import {
fetchAutomationRules,
resetAutomationRuleSequence,
updateAutomationRule,
} from "@/lib/automation/api";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -18,6 +22,8 @@ const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
SWARM_MOOD_HIGH: "Hohe Schwarmstimmung",
COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch",
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand",
HONEY_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Honigchargen)",
BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)",
};
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
@@ -33,8 +39,26 @@ const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
"Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.",
STOCK_ITEM_LOW:
"Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.",
HONEY_BATCH_NUMBER_FORMAT:
"Wenn aktiv, wird die Chargennummer neuer Honigchargen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
BOTTLING_LOT_NUMBER_FORMAT:
"Wenn aktiv, wird die Los-/Chargennummer neuer Abfüllungen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
};
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
"HONEY_BATCH_NUMBER_FORMAT",
"BOTTLING_LOT_NUMBER_FORMAT",
];
function isNumberFormatRule(rule: AutomationRule): boolean {
return NUMBER_FORMAT_TRIGGER_TYPES.includes(rule.triggerType);
}
function ruleFormat(rule: AutomationRule): string {
const config = rule.config as { format?: string } | null;
return config?.format ?? "";
}
function ruleThreshold(rule: AutomationRule): string {
const config = rule.config as { thresholdMitesPerDay?: number } | null;
return String(config?.thresholdMitesPerDay ?? "");
@@ -44,6 +68,8 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
const queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [threshold, setThreshold] = useState(ruleThreshold(rule));
const [format, setFormat] = useState(ruleFormat(rule));
const numberFormatRule = isNumberFormatRule(rule);
const mutation = useMutation({
mutationFn: (input: { active?: boolean; config?: Record<string, unknown>; actionTemplate?: string }) =>
@@ -55,7 +81,20 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
onError: () => toast.error("Regel konnte nicht gespeichert werden"),
});
const resetMutation = useMutation({
mutationFn: () => resetAutomationRuleSequence(rule.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
toast.success("Zähler zurückgesetzt");
},
onError: () => toast.error("Zähler konnte nicht zurückgesetzt werden"),
});
function handleSave() {
if (numberFormatRule) {
mutation.mutate({ config: { format } });
return;
}
const input: { config?: Record<string, unknown>; actionTemplate?: string } = {
actionTemplate: actionTemplate || undefined,
};
@@ -75,14 +114,33 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<p className="text-muted-foreground">{TRIGGER_DESCRIPTIONS[rule.triggerType]}</p>
<div className="flex flex-col gap-2">
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
<Input
id={`template-${rule.id}`}
value={actionTemplate}
onChange={(event) => setActionTemplate(event.target.value)}
/>
</div>
{numberFormatRule ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`format-${rule.id}`}>Format</Label>
<Input
id={`format-${rule.id}`}
value={format}
placeholder="WH-{YYYY}-{SEQ:4}"
onChange={(event) => setFormat(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
Nummer, {"{SEQ:4}"} mit führenden Nullen auf 4 Stellen.
</p>
<p className="text-xs text-muted-foreground">
Aktueller Zähler: {rule.sequenceValue}
</p>
</div>
) : (
<div className="flex flex-col gap-2">
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
<Input
id={`template-${rule.id}`}
value={actionTemplate}
onChange={(event) => setActionTemplate(event.target.value)}
/>
</div>
)}
{rule.triggerType === "VARROA_THRESHOLD" ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label>
@@ -108,6 +166,17 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</div>
{numberFormatRule ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={resetMutation.isPending}
onClick={() => resetMutation.mutate()}
>
{resetMutation.isPending ? "Wird zurückgesetzt…" : "Zähler zurücksetzen"}
</Button>
) : null}
</CardContent>
</Card>
);
@@ -17,6 +17,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [youtubeUrl, setYoutubeUrl] = useState(settings.youtubeUrl ?? "");
const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? "");
const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? "");
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const mutation = useMutation({
mutationFn: updateSiteSettings,
@@ -33,6 +34,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
youtubeUrl,
instagramUrl,
tiktokUrl,
websiteUrl,
});
}
@@ -74,6 +76,16 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
onChange={(event) => setTiktokUrl(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="site-website">Webseite (z. B. Onlineshop)</Label>
<Input
id="site-website"
type="url"
placeholder="https://shop.wabenhain.de"
value={websiteUrl}
onChange={(event) => setWebsiteUrl(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
@@ -1,10 +1,19 @@
"use client";
import { ChangeEvent, useState } from "react";
import { ChangeEvent, FormEvent, useState } from "react";
import { Database } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { exportBackup, getBackupSchedule, importBackup, runBackupNow } from "@/lib/backup/api";
import {
exportBackup,
getBackupSchedule,
getBackupSettings,
importBackup,
runBackupNow,
updateBackupSettings,
} from "@/lib/backup/api";
import { Input } from "@/components/ui/input";
import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
import { Badge } from "@/components/ui/badge";
@@ -120,6 +129,104 @@ function formatDateTime(value: string | null): string {
return new Date(value).toLocaleString("de-DE");
}
// Mounted only once `settings` has arrived, so its useState initial values
// already reflect the fetched data - matches the pattern already used for
// WebsiteSettingsForm in social-media-tab.tsx.
function ScheduleSettingsForm({
settings,
envOverrides,
}: {
settings: BackupSettings;
envOverrides: BackupScheduleStatus["envOverrides"];
}) {
const queryClient = useQueryClient();
const [storagePath, setStoragePath] = useState(settings.storagePath ?? "");
const [cronSchedule, setCronSchedule] = useState(settings.cronSchedule ?? "");
const [retentionCount, setRetentionCount] = useState(
settings.retentionCount !== null ? String(settings.retentionCount) : "",
);
const mutation = useMutation({
mutationFn: updateBackupSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["backup-schedule"] });
queryClient.invalidateQueries({ queryKey: ["backup-settings"] });
toast.success("Zeitplan gespeichert");
},
onError: () => toast.error("Zeitplan konnte nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const retention = Number(retentionCount);
mutation.mutate({
storagePath,
cronSchedule,
retentionCount: retentionCount !== "" && Number.isInteger(retention) ? retention : undefined,
});
}
return (
<form className="flex flex-col gap-3 border-t border-border/60 pt-4" onSubmit={handleSubmit}>
<p className="text-xs text-muted-foreground">
Nur wirksam, solange die zugehörige Umgebungsvariable nicht gesetzt ist - Env hat immer
Vorrang.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="backup-path">Pfad</Label>
<Input
id="backup-path"
value={storagePath}
disabled={envOverrides.storagePath}
onChange={(event) => setStoragePath(event.target.value)}
placeholder="/data/backups"
/>
{envOverrides.storagePath ? (
<p className="text-xs text-muted-foreground">
Durch BACKUP_STORAGE_PATH vorgegeben.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="backup-cron">Zeitplan (Cron-Ausdruck)</Label>
<Input
id="backup-cron"
value={cronSchedule}
disabled={envOverrides.cronSchedule}
onChange={(event) => setCronSchedule(event.target.value)}
placeholder="0 3 * * *"
/>
{envOverrides.cronSchedule ? (
<p className="text-xs text-muted-foreground">
Durch BACKUP_CRON_SCHEDULE vorgegeben.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="backup-retention">Aufbewahrung (Anzahl Backups)</Label>
<Input
id="backup-retention"
type="number"
min={1}
max={365}
value={retentionCount}
disabled={envOverrides.retentionCount}
onChange={(event) => setRetentionCount(event.target.value)}
placeholder="14"
/>
{envOverrides.retentionCount ? (
<p className="text-xs text-muted-foreground">
Durch BACKUP_RETENTION_COUNT vorgegeben.
</p>
) : null}
</div>
<Button type="submit" variant="outline" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Zeitplan speichern"}
</Button>
</form>
);
}
function ScheduleSection() {
const queryClient = useQueryClient();
@@ -127,6 +234,10 @@ function ScheduleSection() {
queryKey: ["backup-schedule"],
queryFn: getBackupSchedule,
});
const settingsQuery = useQuery({
queryKey: ["backup-settings"],
queryFn: getBackupSettings,
});
const runNowMutation = useMutation({
mutationFn: runBackupNow,
@@ -149,8 +260,8 @@ function ScheduleSection() {
<p className="text-muted-foreground">Lädt…</p>
) : !status?.enabled ? (
<p className="text-muted-foreground">
Automatische Backups sind nicht konfiguriert (BACKUP_STORAGE_PATH fehlt).
Manueller Export/Import bleibt weiterhin möglich.
Automatische Backups sind noch nicht konfiguriert - weder per Umgebungsvariable noch
über die GUI unten. Manueller Export/Import bleibt weiterhin möglich.
</p>
) : (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
@@ -176,6 +287,9 @@ function ScheduleSection() {
>
{runNowMutation.isPending ? "Wird gesichert…" : "Jetzt sichern"}
</Button>
{settingsQuery.data && status ? (
<ScheduleSettingsForm settings={settingsQuery.data} envOverrides={status.envOverrides} />
) : null}
</CardContent>
</Card>
);
+122 -53
View File
@@ -2,7 +2,19 @@
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Hexagon, Droplet, MapPin, CalendarDays, ExternalLink } from "lucide-react";
import {
Hexagon,
Droplet,
MapPin,
CalendarDays,
PackageCheck,
Sparkles,
Video,
Camera,
Music2,
Globe,
type LucideIcon,
} from "lucide-react";
import { fetchPublicBatch } from "@/lib/public-batch/api";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -15,6 +27,50 @@ function formatMonthYear(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", { month: "long", year: "numeric" });
}
// lucide-react has no brand icons (YouTube/Instagram/TikTok aren't part of
// its generic icon set) - these are the closest generic stand-ins that still
// read at a glance as "video platform" / "photo platform" / "short clips".
const SOCIAL_LINK_ICONS: Record<string, LucideIcon> = {
YouTube: Video,
Instagram: Camera,
TikTok: Music2,
Webseite: Globe,
};
interface InfoRowProps {
icon: LucideIcon;
label: string;
value: string;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<div className="flex items-start gap-2.5">
<Icon className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
</div>
);
}
interface StatProps {
icon: LucideIcon;
label: string;
value: string;
}
function Stat({ icon: Icon, label, value }: StatProps) {
return (
<div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center">
<Icon className="size-4 text-primary" />
<span className="text-sm font-semibold">{value}</span>
<span className="text-[0.7rem] text-muted-foreground">{label}</span>
</div>
);
}
export default function PublicBatchPage() {
const params = useParams<{ lot: string }>();
const lot = decodeURIComponent(params.lot);
@@ -28,12 +84,20 @@ export default function PublicBatchPage() {
const harvestMonths = batch
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
: null;
const apiaryNames = batch ? batch.apiaries.map((a) => a.name).join(", ") : null;
// Standort statt Bienenstand-Name: mehrere Bienenstände am selben Ort
// sollen als ein Herkunftsort erscheinen, nicht als mehrere interne Namen.
// Fällt auf den Namen zurück, falls für einen Bienenstand kein Standort
// hinterlegt ist, damit die Zeile nie leer bleibt.
const originLabels = batch
? [...new Set(batch.apiaries.map((a) => a.location?.trim() || a.name))]
: [];
const originText = originLabels.join(", ");
const socialLinks = batch
? [
{ label: "YouTube", url: batch.socialLinks.youtube },
{ label: "Instagram", url: batch.socialLinks.instagram },
{ label: "TikTok", url: batch.socialLinks.tiktok },
{ label: "Webseite", url: batch.socialLinks.website },
].filter((link): link is { label: string; url: string } => link.url !== null)
: [];
@@ -55,7 +119,7 @@ export default function PublicBatchPage() {
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<CardContent className="flex flex-col gap-5">
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
@@ -65,69 +129,74 @@ export default function PublicBatchPage() {
{batch ? (
<>
<div>
<h1 className="font-heading text-2xl font-semibold">
{batch.productName ?? "Honig"}
</h1>
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-heading text-2xl font-semibold">
{batch.productName ?? "Honig"}
</h1>
{batch.honeyType ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
<Sparkles className="size-3" />
{batch.honeyType}
</span>
) : null}
</div>
<p className="font-mono text-sm text-muted-foreground">Charge {batch.lotNumber}</p>
</div>
{batch.productDescription ? (
<p className="text-sm text-muted-foreground">{batch.productDescription}</p>
) : null}
<dl className="flex flex-col gap-3 text-sm">
{apiaryNames ? (
<div className="flex items-start gap-2.5">
<MapPin className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Herkunft</dt>
<dd className="font-medium">{apiaryNames}</dd>
</div>
</div>
{originText ? (
<InfoRow icon={MapPin} label="Herkunft" value={originText} />
) : null}
{harvestMonths ? (
<div className="flex items-start gap-2.5">
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Geerntet</dt>
<dd className="font-medium">{harvestMonths}</dd>
</div>
</div>
<InfoRow icon={CalendarDays} label="Geerntet" value={harvestMonths} />
) : null}
{batch.extractedDate ? (
<div className="flex items-start gap-2.5">
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Geschleudert</dt>
<dd className="font-medium">{formatDate(batch.extractedDate)}</dd>
</div>
</div>
) : null}
{batch.waterContent !== null ? (
<div className="flex items-start gap-2.5">
<Droplet className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Wassergehalt</dt>
<dd className="font-medium">{batch.waterContent} %</dd>
</div>
</div>
<InfoRow
icon={Droplet}
label="Geschleudert"
value={formatDate(batch.extractedDate)}
/>
) : null}
<InfoRow
icon={PackageCheck}
label="Abgefüllt"
value={formatDate(batch.bottledDate)}
/>
</dl>
<p className="text-xs text-muted-foreground">
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
</p>
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
{batch.waterContent !== null ? (
<Stat icon={Droplet} label="Wassergehalt" value={`${batch.waterContent} %`} />
) : null}
<Stat icon={PackageCheck} label="Gebinde" value={`${batch.jarSize} g`} />
<Stat
icon={CalendarDays}
label="Haltbar bis"
value={formatDate(batch.bestBeforeDate)}
/>
</div>
{socialLinks.length > 0 ? (
<div className="flex flex-wrap gap-3 border-t border-border pt-3">
{socialLinks.map((link) => (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<ExternalLink className="size-4" />
{link.label}
</a>
))}
<div className="flex flex-wrap gap-2 border-t border-border pt-4">
{socialLinks.map((link) => {
const Icon = SOCIAL_LINK_ICONS[link.label] ?? Globe;
return (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/20"
>
<Icon className="size-4" />
{link.label}
</a>
);
})}
</div>
) : null}
</>