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:
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user