feat(export): Bestandsbuch für Tierarzneimittel nach Art. 108 VO (EU) 2019/6

- Treatment records supplier, purchase proof and prescribing vet, and
  keeps the apiary the colony stood at on the treatment day.
- Holder name and address in site settings (never in public output;
  regression test in public.service.spec).
- treatment-log.pdf: one sheet per apiary, identical treatments grouped,
  missing mandatory fields printed as FEHLT, uncertain locations marked.
- Shared PDF table helpers extracted from the record card.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 11:10:07 +02:00
1 parent f263ec79f2
commit 0d23963f07
24 files changed
+941 -113

No files matched your search

@@ -5,7 +5,7 @@ import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { Button } from "@/components/ui/button";
@@ -26,6 +26,11 @@ export default function ApiaryDetailPage() {
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
});
const treatmentLogMutation = useMutation({
mutationFn: () => exportTreatmentLogPdf(CURRENT_YEAR, params.id),
onError: () => toast.error("Bestandsbuch konnte nicht erstellt werden. Bitte noch einmal versuchen."),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
@@ -52,6 +57,13 @@ export default function ApiaryDetailPage() {
>
Neue Ernte
</Button>
<Button
variant="outline"
disabled={treatmentLogMutation.isPending}
onClick={() => treatmentLogMutation.mutate()}
>
{treatmentLogMutation.isPending ? "Wird erstellt…" : `Bestandsbuch ${CURRENT_YEAR}`}
</Button>
<Button
variant="outline"
disabled={exportReportMutation.isPending}
@@ -32,6 +32,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const [method, setMethod] = useState(treatment.method ?? "");
const [reason, setReason] = useState(treatment.reason ?? "");
const [batchNumber, setBatchNumber] = useState(treatment.batchNumber ?? "");
const [supplier, setSupplier] = useState(treatment.supplier ?? "");
const [purchaseProof, setPurchaseProof] = useState(treatment.purchaseProof ?? "");
const [prescribingVet, setPrescribingVet] = useState(treatment.prescribingVet ?? "");
const [notes, setNotes] = useState(treatment.notes ?? "");
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
treatment.withdrawalPeriodDays?.toString() ?? "",
@@ -52,6 +55,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
batchNumber: batchNumber || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
supplier: supplier.trim(),
purchaseProof: purchaseProof.trim(),
prescribingVet: prescribingVet.trim(),
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
@@ -178,6 +184,42 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
/>
</div>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend>
<p className="text-xs text-muted-foreground">
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108
VO (EU) 2019/6). Belege 5 Jahre aufbewahren.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-supplier">Lieferant (Name, Anschrift)</Label>
<Input
id="treatment-edit-supplier"
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier}
onChange={(event) => setSupplier(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-proof">Beleg für den Erwerb</Label>
<Input
id="treatment-edit-proof"
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-vet">Verschreibender Tierarzt (falls zutreffend)</Label>
<Input
id="treatment-edit-vet"
className="h-11"
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
</div>
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-notes">Notizen</Label>
<Textarea
@@ -26,7 +26,7 @@ const TABS: { value: Tab; label: string }[] = [
{ value: "webhooks", label: "Webhooks" },
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
{ value: "label-templates", label: "Etikettenvorlagen" },
{ value: "social-media", label: "Social Media" },
{ value: "social-media", label: "Stammdaten & Web" },
{ value: "rules", label: "Regeln" },
{ value: "calendar-sync", label: "Kalender-Sync" },
{ value: "api-keys", label: "API-Schlüssel" },
@@ -18,6 +18,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? "");
const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? "");
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const [holderName, setHolderName] = useState(settings.holderName ?? "");
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
const mutation = useMutation({
mutationFn: updateSiteSettings,
@@ -35,6 +37,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
instagramUrl,
tiktokUrl,
websiteUrl,
holderName,
holderAddress,
});
}
@@ -86,6 +90,28 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
onChange={(event) => setWebsiteUrl(event.target.value)}
/>
</div>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Bienenhalter (für das Bestandsbuch)</legend>
<p className="text-xs text-muted-foreground">
Erscheint nur im Bestandsbuch, nie auf öffentlichen Seiten.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="site-holder-name">Name</Label>
<Input
id="site-holder-name"
value={holderName}
onChange={(event) => setHolderName(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="site-holder-address">Anschrift</Label>
<Input
id="site-holder-address"
value={holderAddress}
onChange={(event) => setHolderAddress(event.target.value)}
/>
</div>
</fieldset>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
@@ -51,6 +51,9 @@ export default function NewTreatmentPage() {
const [batchNumber, setBatchNumber] = useState("");
const [notes, setNotes] = useState("");
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
const [supplier, setSupplier] = useState("");
const [purchaseProof, setPurchaseProof] = useState("");
const [prescribingVet, setPrescribingVet] = useState("");
const [error, setError] = useState<string | null>(null);
// One key per opened form: a double tap or an offline retry of the
@@ -98,6 +101,9 @@ export default function NewTreatmentPage() {
batchNumber: batchNumber || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
supplier: supplier.trim() || undefined,
purchaseProof: purchaseProof.trim() || undefined,
prescribingVet: prescribingVet.trim() || undefined,
});
}
@@ -229,6 +235,42 @@ export default function NewTreatmentPage() {
geerntet werden.
</p>
</div>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend>
<p className="text-xs text-muted-foreground">
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108
VO (EU) 2019/6). Belege 5 Jahre aufbewahren.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-supplier">Lieferant (Name, Anschrift)</Label>
<Input
id="treatment-supplier"
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier}
onChange={(event) => setSupplier(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-proof">Beleg für den Erwerb</Label>
<Input
id="treatment-proof"
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-vet">Verschreibender Tierarzt (falls zutreffend)</Label>
<Input
id="treatment-vet"
className="h-11"
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
</div>
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-notes">Notizen</Label>
<Textarea
+10
View File
@@ -49,6 +49,16 @@ export async function exportColonyRecordCardPdf(colonyId: string, code: string):
downloadBlob(blob, filename);
}
/** Bestandsbuch (Art. 108 VO (EU) 2019/6): all treatments of a year, one sheet per apiary. */
export async function exportTreatmentLogPdf(year: number, apiaryId?: string): Promise<void> {
const query = apiaryId ? `year=${year}&apiaryId=${apiaryId}` : `year=${year}`;
const { blob, filename } = await apiFetchBlob(
`/export/treatment-log.pdf?${query}`,
`bestandsbuch-${year}.pdf`,
);
downloadBlob(blob, filename);
}
export async function exportApiaryAnnualReportPdf(apiaryId: string, year: number): Promise<void> {
const { blob, filename } = await apiFetchBlob(
`/export/apiary-annual-report.pdf?apiaryId=${apiaryId}&year=${year}`,
@@ -4,6 +4,8 @@ export interface SiteSettings {
instagramUrl: string | null;
tiktokUrl: string | null;
websiteUrl: string | null;
holderName: string | null;
holderAddress: string | null;
updatedAt: string;
}
@@ -12,4 +14,6 @@ export interface UpdateSiteSettingsInput {
instagramUrl?: string;
tiktokUrl?: string;
websiteUrl?: string;
holderName?: string;
holderAddress?: string;
}
@@ -13,6 +13,9 @@ export interface Treatment {
batchNumber: string | null;
notes: string | null;
withdrawalPeriodDays: number | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
createdAt: string;
}
@@ -29,6 +32,9 @@ export interface CreateTreatmentInput {
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
supplier?: string;
purchaseProof?: string;
prescribingVet?: string;
}
export interface UpdateTreatmentInput {
@@ -45,6 +51,9 @@ export interface UpdateTreatmentInput {
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
supplier?: string;
purchaseProof?: string;
prescribingVet?: string;
}
export interface WithdrawalStatus {