feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
- 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:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user