feat: Abfüllungs-Archiv und Social-Media-Links auf der Herkunftsseite
Abfüllungen hatten keine Übersichtsseite - die Navigation ging direkt auf /bottling/new, sodass eine angelegte Abfüllung danach nirgends mehr sichtbar war. Neue Seite /bottling listet alle Abfüllungen (Archiv), Klick führt zur bestehenden Detail-/Bearbeiten-Seite. Navigationslink zeigt jetzt auf die Liste statt direkt auf "Neu". Neues Modul site-settings (Singleton-Tabelle SiteSettings) für YouTube-/Instagram-/TikTok-Links, verwaltbar unter Verwaltung > Website (neue Berechtigung site-settings:read/update). Die öffentliche Herkunftsseite (/herkunft/[lot]) zeigt die konfigurierten Links jetzt zusätzlich zur Chargen-Herkunft an - PublicService liefert sie über das bereits oeffentliche /public/batches/:lotNumber mit aus, keine zusaetzliche unauthentifizierte Route noetig. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
33fa768062
commit
351f9ef34c
19 files changed
+612
-4
No files matched your search
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Package } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchBottlingBatches } from "@/lib/bottling/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE");
|
||||
}
|
||||
|
||||
export default function BottlingBatchesPage() {
|
||||
const { data: bottlingBatches, isLoading, isError } = useQuery({
|
||||
queryKey: ["bottling-batches"],
|
||||
queryFn: () => fetchBottlingBatches(),
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Package}
|
||||
title="Abfüllungen"
|
||||
description="Archiv aller abgefüllten Chargen"
|
||||
actions={
|
||||
<Button nativeButton={false} render={<Link href="/bottling/new" />}>
|
||||
+ Neue Abfüllung
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Abfüllungen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{bottlingBatches && bottlingBatches.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Noch keine Abfüllungen"
|
||||
description="Lege eine Abfüllung an, sobald du Honig in Gläser gefüllt hast."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{bottlingBatches && bottlingBatches.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{bottlingBatches.map((bottlingBatch) => (
|
||||
<EntityCard
|
||||
key={bottlingBatch.id}
|
||||
href={`/bottling/${bottlingBatch.id}`}
|
||||
icon={Package}
|
||||
title={<span className="font-mono">{bottlingBatch.lotNumber}</span>}
|
||||
subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber}
|
||||
fields={[
|
||||
{
|
||||
label: "Menge",
|
||||
value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`,
|
||||
},
|
||||
{ label: "Datum", value: formatDate(bottlingBatch.date) },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
|
||||
import { SiteSettings } from "@/lib/site-settings/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
// Mounted only once `settings` has arrived, so its useState initial values
|
||||
// already reflect the fetched data - no synchronization effect needed.
|
||||
function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [youtubeUrl, setYoutubeUrl] = useState(settings.youtubeUrl ?? "");
|
||||
const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? "");
|
||||
const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? "");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateSiteSettings,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["site-settings"] });
|
||||
toast.success("Website-Einstellungen gespeichert");
|
||||
},
|
||||
onError: () => toast.error("Einstellungen konnten nicht gespeichert werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
mutation.mutate({
|
||||
youtubeUrl,
|
||||
instagramUrl,
|
||||
tiktokUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wird auf jeder Herkunftsseite (/herkunft/…) angezeigt. Feld leer lassen, um einen Link
|
||||
zu entfernen.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="site-youtube">YouTube</Label>
|
||||
<Input
|
||||
id="site-youtube"
|
||||
type="url"
|
||||
placeholder="https://youtube.com/@…"
|
||||
value={youtubeUrl}
|
||||
onChange={(event) => setYoutubeUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="site-instagram">Instagram</Label>
|
||||
<Input
|
||||
id="site-instagram"
|
||||
type="url"
|
||||
placeholder="https://instagram.com/…"
|
||||
value={instagramUrl}
|
||||
onChange={(event) => setInstagramUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="site-tiktok">TikTok</Label>
|
||||
<Input
|
||||
id="site-tiktok"
|
||||
type="url"
|
||||
placeholder="https://tiktok.com/@…"
|
||||
value={tiktokUrl}
|
||||
onChange={(event) => setTiktokUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebsiteSettingsPage() {
|
||||
const { data: settings, isLoading, isError } = useQuery({
|
||||
queryKey: ["site-settings"],
|
||||
queryFn: fetchSiteSettings,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Globe}
|
||||
title="Website"
|
||||
description="Social-Media-Links auf der öffentlichen Herkunftsseite"
|
||||
gradient="var(--gradient-sky)"
|
||||
/>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Einstellungen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{settings ? <WebsiteSettingsForm settings={settings} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Droplet,
|
||||
FileClock,
|
||||
FlaskConical,
|
||||
Globe,
|
||||
Hexagon,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
@@ -74,7 +75,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{ 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: "Abfüllungen", href: "/bottling", icon: Package },
|
||||
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
||||
{ label: "Lager", href: "/storage", icon: Warehouse },
|
||||
{ label: "Inventar", href: "/equipment", icon: Wrench },
|
||||
@@ -112,6 +113,12 @@ const ADMIN_GROUP: NavGroup = {
|
||||
permission: "label-templates:read",
|
||||
},
|
||||
{ label: "Import", href: "/settings/import", icon: Upload, permission: "import:execute" },
|
||||
{
|
||||
label: "Website",
|
||||
href: "/settings/website",
|
||||
icon: Globe,
|
||||
permission: "site-settings:read",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Hexagon, Droplet, MapPin, CalendarDays } from "lucide-react";
|
||||
import { Hexagon, Droplet, MapPin, CalendarDays, ExternalLink } 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";
|
||||
@@ -29,6 +29,13 @@ export default function PublicBatchPage() {
|
||||
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
|
||||
: null;
|
||||
const apiaryNames = batch ? batch.apiaries.map((a) => a.name).join(", ") : null;
|
||||
const socialLinks = batch
|
||||
? [
|
||||
{ label: "YouTube", url: batch.socialLinks.youtube },
|
||||
{ label: "Instagram", url: batch.socialLinks.instagram },
|
||||
{ label: "TikTok", url: batch.socialLinks.tiktok },
|
||||
].filter((link): link is { label: string; url: string } => link.url !== null)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||
@@ -107,6 +114,22 @@ export default function PublicBatchPage() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
|
||||
</p>
|
||||
{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>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
Reference in new issue
Block a user