From 3f7c4ed404bc0688fa066028cce11f0672a2ee65 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 25 Aug 2026 11:51:41 +0200 Subject: [PATCH] feat: Jahresbericht-PDF pro Volk/Stand Neue PDF-Reports im Export-Modul: colony-annual-report (Durchsichten, Behandlungen, Varroakontrollen, Schwarmkontrolle, Ernten eines Volkes fuer ein Jahr) und apiary-annual-report (Voelker-Uebersicht + Ernten eines Stands fuer ein Jahr). Jahr ist optional, faellt auf das aktuelle Jahr zurueck. Frontend: "Jahresbericht"-Button auf Volk- und Stand-Detailseite, laedt das PDF ueber den bestehenden Blob-Download-Mechanismus herunter. --- ApiCore-Backend/src/export/annual-reports.ts | 372 ++++++++++++++++++ .../src/export/export.controller.ts | 10 +- .../src/export/export.service.spec.ts | 85 +++- ApiCore-Backend/src/export/export.service.ts | 76 +++- ApiCore-Backend/test/export.e2e-spec.ts | 43 ++ .../src/app/(app)/apiaries/[id]/page.tsx | 33 +- .../src/app/(app)/colonies/[id]/page.tsx | 21 +- ApiCore-Frontend/src/lib/export/api.ts | 16 + 8 files changed, 630 insertions(+), 26 deletions(-) create mode 100644 ApiCore-Backend/src/export/annual-reports.ts diff --git a/ApiCore-Backend/src/export/annual-reports.ts b/ApiCore-Backend/src/export/annual-reports.ts new file mode 100644 index 0000000..7a27384 --- /dev/null +++ b/ApiCore-Backend/src/export/annual-reports.ts @@ -0,0 +1,372 @@ +import { NotFoundException } from '@nestjs/common'; +import PDFDocument from 'pdfkit'; +import { PrismaService } from '../prisma/prisma.service'; + +function yearRange(year: number): { start: Date; end: Date } { + return { + start: new Date(Date.UTC(year, 0, 1)), + end: new Date(Date.UTC(year + 1, 0, 1)), + }; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString('de-DE'); +} + +const SWARM_MOOD_LABELS: Record = { + NONE: 'Keine', + LOW: 'Gering', + HIGH: 'Hoch', +}; + +const SWARM_ACTION_LABELS: Record = { + NONE: 'Keine', + QUEEN_CELLS_REMOVED: 'Weiselzellen gebrochen', + ARTIFICIAL_SWARM: 'Kunstschwarm gebildet', + COLONY_DIVIDED: 'Volk geteilt', + OTHER: 'Sonstiges', +}; + +export interface ColonyAnnualReport { + colony: { id: string; code: string; colonyType: string; status: string }; + apiary: { id: string; name: string } | null; + year: number; + inspections: { + date: Date; + colonyStrength: number | null; + queenSeen: boolean | null; + broodAmount: string | null; + }[]; + treatments: { date: Date; product: string; amount: string | null }[]; + varroaMeasurements: { + date: Date; + method: string; + miteCount: number | null; + calculatedValue: number | null; + }[]; + swarmControlChecks: { + date: Date; + queenCellsFound: number | null; + queenCellsCapped: boolean; + swarmMood: string; + actionTaken: string; + }[]; + harvests: { + date: Date; + honeyType: string | null; + netWeight: number | null; + }[]; +} + +export interface ApiaryAnnualReport { + apiary: { id: string; name: string }; + year: number; + colonies: { + code: string; + inspectionCount: number; + treatmentCount: number; + harvestCount: number; + }[]; + harvests: { + date: Date; + honeyType: string | null; + netWeight: number | null; + grossWeight: number | null; + }[]; +} + +export async function buildColonyAnnualReport( + prisma: PrismaService, + colonyId: string, + year: number, +): Promise { + const colony = await prisma.colony.findUnique({ + where: { id: colonyId }, + include: { apiary: true }, + }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + const { start, end } = yearRange(year); + const dateInYear = { gte: start, lt: end }; + + const [ + inspections, + treatments, + varroaMeasurements, + swarmControlChecks, + harvestLinks, + ] = await Promise.all([ + prisma.inspection.findMany({ + where: { colonyId, date: dateInYear }, + orderBy: { date: 'asc' }, + }), + prisma.treatment.findMany({ + where: { colonyId, date: dateInYear }, + orderBy: { date: 'asc' }, + }), + prisma.varroaMeasurement.findMany({ + where: { colonyId, date: dateInYear }, + orderBy: { date: 'asc' }, + }), + prisma.swarmControlCheck.findMany({ + where: { colonyId, date: dateInYear }, + orderBy: { date: 'asc' }, + }), + prisma.harvestColony.findMany({ + where: { colonyId, harvest: { date: dateInYear } }, + include: { harvest: true }, + orderBy: { harvest: { date: 'asc' } }, + }), + ]); + + return { + colony: { + id: colony.id, + code: colony.code, + colonyType: colony.colonyType, + status: colony.status, + }, + apiary: colony.apiary + ? { id: colony.apiary.id, name: colony.apiary.name } + : null, + year, + inspections: inspections.map((entry) => ({ + date: entry.date, + colonyStrength: entry.colonyStrength, + queenSeen: entry.queenSeen, + broodAmount: entry.broodAmount, + })), + treatments: treatments.map((entry) => ({ + date: entry.date, + product: entry.product, + amount: entry.amount, + })), + varroaMeasurements: varroaMeasurements.map((entry) => ({ + date: entry.date, + method: entry.method, + miteCount: entry.miteCount, + calculatedValue: entry.calculatedValue, + })), + swarmControlChecks: swarmControlChecks.map((entry) => ({ + date: entry.date, + queenCellsFound: entry.queenCellsFound, + queenCellsCapped: entry.queenCellsCapped, + swarmMood: entry.swarmMood, + actionTaken: entry.actionTaken, + })), + harvests: harvestLinks.map((link) => ({ + date: link.harvest.date, + honeyType: link.harvest.honeyType, + netWeight: link.harvest.netWeight, + })), + }; +} + +export async function buildApiaryAnnualReport( + prisma: PrismaService, + apiaryId: string, + year: number, +): Promise { + const apiary = await prisma.apiary.findUnique({ where: { id: apiaryId } }); + if (!apiary) { + throw new NotFoundException('Apiary not found'); + } + const { start, end } = yearRange(year); + const dateInYear = { gte: start, lt: end }; + + const colonies = await prisma.colony.findMany({ + where: { apiaryId }, + orderBy: { code: 'asc' }, + }); + const harvests = await prisma.harvest.findMany({ + where: { apiaryId, date: dateInYear }, + orderBy: { date: 'asc' }, + }); + + const colonySummaries = await Promise.all( + colonies.map(async (colony) => { + const [inspectionCount, treatmentCount, harvestCount] = await Promise.all( + [ + prisma.inspection.count({ + where: { colonyId: colony.id, date: dateInYear }, + }), + prisma.treatment.count({ + where: { colonyId: colony.id, date: dateInYear }, + }), + prisma.harvestColony.count({ + where: { colonyId: colony.id, harvest: { date: dateInYear } }, + }), + ], + ); + return { + code: colony.code, + inspectionCount, + treatmentCount, + harvestCount, + }; + }), + ); + + return { + apiary: { id: apiary.id, name: apiary.name }, + year, + colonies: colonySummaries, + harvests: harvests.map((harvest) => ({ + date: harvest.date, + honeyType: harvest.honeyType, + netWeight: harvest.netWeight, + grossWeight: harvest.grossWeight, + })), + }; +} + +export function renderColonyAnnualReportPdf( + report: ColonyAnnualReport, +): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ margin: 50 }); + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', (error: Error) => reject(error)); + + doc.fontSize(18).text(`Jahresbericht Volk ${report.colony.code}`, { + underline: true, + }); + doc.fontSize(12).text(`Jahr: ${report.year}`); + if (report.apiary) { + doc.text(`Stand: ${report.apiary.name}`); + } + doc.text(`Status: ${report.colony.status}`); + doc.moveDown(); + + doc.fontSize(14).text(`Durchsichten (${report.inspections.length})`); + doc.fontSize(12); + if (report.inspections.length === 0) { + doc.text('Keine Durchsichten im Berichtsjahr.'); + } + for (const entry of report.inspections) { + doc.text( + `- ${formatDate(entry.date)}: Staerke ${entry.colonyStrength ?? '-'}, ` + + `Koenigin gesehen: ${entry.queenSeen === null ? '-' : entry.queenSeen ? 'ja' : 'nein'}, ` + + `Brut: ${entry.broodAmount ?? '-'}`, + ); + } + doc.moveDown(); + + doc.fontSize(14).text(`Behandlungen (${report.treatments.length})`); + doc.fontSize(12); + if (report.treatments.length === 0) { + doc.text('Keine Behandlungen im Berichtsjahr.'); + } + for (const entry of report.treatments) { + doc.text( + `- ${formatDate(entry.date)}: ${entry.product} (${entry.amount ?? '-'})`, + ); + } + doc.moveDown(); + + doc + .fontSize(14) + .text(`Varroakontrollen (${report.varroaMeasurements.length})`); + doc.fontSize(12); + if (report.varroaMeasurements.length === 0) { + doc.text('Keine Varroakontrollen im Berichtsjahr.'); + } + for (const entry of report.varroaMeasurements) { + doc.text( + `- ${formatDate(entry.date)}: ${entry.method}, ` + + `${entry.miteCount ?? '-'} Milben` + + (entry.calculatedValue !== null + ? ` (${entry.calculatedValue.toFixed(1)} Milben/Tag)` + : ''), + ); + } + doc.moveDown(); + + doc + .fontSize(14) + .text(`Schwarmkontrolle (${report.swarmControlChecks.length})`); + doc.fontSize(12); + if (report.swarmControlChecks.length === 0) { + doc.text('Keine Schwarmkontrolle im Berichtsjahr.'); + } + for (const entry of report.swarmControlChecks) { + doc.text( + `- ${formatDate(entry.date)}: ${entry.queenCellsFound ?? 0} Weiselzelle(n)` + + `${entry.queenCellsCapped ? ' (verdeckelt)' : ''}, ` + + `Stimmung: ${SWARM_MOOD_LABELS[entry.swarmMood] ?? entry.swarmMood}, ` + + `Massnahme: ${SWARM_ACTION_LABELS[entry.actionTaken] ?? entry.actionTaken}`, + ); + } + doc.moveDown(); + + doc.fontSize(14).text(`Ernten (${report.harvests.length})`); + doc.fontSize(12); + if (report.harvests.length === 0) { + doc.text('Keine Ernten im Berichtsjahr.'); + } + for (const entry of report.harvests) { + doc.text( + `- ${formatDate(entry.date)}: ${entry.honeyType ?? '-'}, ` + + `${entry.netWeight ?? '-'} kg (Stand-Ernte, ggf. mit anderen Voelkern geteilt)`, + ); + } + + doc.end(); + }); +} + +export function renderApiaryAnnualReportPdf( + report: ApiaryAnnualReport, +): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ margin: 50 }); + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', (error: Error) => reject(error)); + + doc.fontSize(18).text(`Jahresbericht Stand ${report.apiary.name}`, { + underline: true, + }); + doc.fontSize(12).text(`Jahr: ${report.year}`); + doc.moveDown(); + + doc.fontSize(14).text(`Voelker (${report.colonies.length})`); + doc.fontSize(12); + if (report.colonies.length === 0) { + doc.text('Keine Voelker an diesem Stand.'); + } + for (const colony of report.colonies) { + doc.text( + `- ${colony.code}: ${colony.inspectionCount} Durchsicht(en), ` + + `${colony.treatmentCount} Behandlung(en), ${colony.harvestCount} Ernte(n)`, + ); + } + doc.moveDown(); + + const totalNetWeight = report.harvests.reduce( + (sum, harvest) => sum + (harvest.netWeight ?? 0), + 0, + ); + doc + .fontSize(14) + .text( + `Ernten (${report.harvests.length}), gesamt ${totalNetWeight.toFixed(1)} kg`, + ); + doc.fontSize(12); + if (report.harvests.length === 0) { + doc.text('Keine Ernten im Berichtsjahr.'); + } + for (const entry of report.harvests) { + doc.text( + `- ${formatDate(entry.date)}: ${entry.honeyType ?? '-'}, ` + + `${entry.netWeight ?? '-'} kg netto / ${entry.grossWeight ?? '-'} kg brutto`, + ); + } + + doc.end(); + }); +} diff --git a/ApiCore-Backend/src/export/export.controller.ts b/ApiCore-Backend/src/export/export.controller.ts index 09b708f..479b50d 100644 --- a/ApiCore-Backend/src/export/export.controller.ts +++ b/ApiCore-Backend/src/export/export.controller.ts @@ -46,9 +46,17 @@ export class ExportController { async reportPdf( @Param('report') report: string, @Query('honeyBatchId') honeyBatchId: string | undefined, + @Query('colonyId') colonyId: string | undefined, + @Query('apiaryId') apiaryId: string | undefined, + @Query('year') year: string | undefined, @Res() res: Response, ): Promise { - const pdf = await this.exportService.exportReportPdf(report, honeyBatchId); + const pdf = await this.exportService.exportReportPdf(report, { + honeyBatchId, + colonyId, + apiaryId, + year, + }); res.setHeader('Content-Type', 'application/pdf'); res.setHeader( 'Content-Disposition', diff --git a/ApiCore-Backend/src/export/export.service.spec.ts b/ApiCore-Backend/src/export/export.service.spec.ts index 06267c1..c396a01 100644 --- a/ApiCore-Backend/src/export/export.service.spec.ts +++ b/ApiCore-Backend/src/export/export.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { ExportService } from './export.service'; import { PrismaService } from '../prisma/prisma.service'; import { HoneyBatchesService } from '../honey-batches/honey-batches.service'; @@ -6,21 +6,42 @@ import { HoneyBatchesService } from '../honey-batches/honey-batches.service'; describe('ExportService', () => { let service: ExportService; let prisma: { - colony: { findMany: jest.Mock }; + colony: { findMany: jest.Mock; findUnique: jest.Mock }; honeyBatch: { findMany: jest.Mock }; - apiary: { findMany: jest.Mock }; + apiary: { findMany: jest.Mock; findUnique: jest.Mock }; queen: { findMany: jest.Mock }; task: { findMany: jest.Mock }; + inspection: { findMany: jest.Mock; count: jest.Mock }; + treatment: { findMany: jest.Mock; count: jest.Mock }; + varroaMeasurement: { findMany: jest.Mock }; + swarmControlCheck: { findMany: jest.Mock }; + harvest: { findMany: jest.Mock }; + harvestColony: { findMany: jest.Mock; count: jest.Mock }; }; let honeyBatchesService: { traceability: jest.Mock }; beforeEach(() => { prisma = { - colony: { findMany: jest.fn() }, + colony: { findMany: jest.fn(), findUnique: jest.fn() }, honeyBatch: { findMany: jest.fn() }, - apiary: { findMany: jest.fn() }, + apiary: { findMany: jest.fn(), findUnique: jest.fn() }, queen: { findMany: jest.fn() }, task: { findMany: jest.fn() }, + inspection: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + treatment: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) }, + swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) }, + harvest: { findMany: jest.fn().mockResolvedValue([]) }, + harvestColony: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, }; honeyBatchesService = { traceability: jest.fn() }; service = new ExportService( @@ -85,7 +106,7 @@ describe('ExportService', () => { it('rejects an unsupported PDF report', async () => { await expect( - service.exportReportPdf('unknown', 'honey-batch-1'), + service.exportReportPdf('unknown', { honeyBatchId: 'honey-batch-1' }), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -115,7 +136,57 @@ describe('ExportService', () => { ], }); - const pdf = await service.exportReportPdf('honey-batch-traceability', 'h1'); + const pdf = await service.exportReportPdf('honey-batch-traceability', { + honeyBatchId: 'h1', + }); + expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF'); + }); + + it('rejects the colony annual report without a colonyId', async () => { + await expect( + service.exportReportPdf('colony-annual-report'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects the colony annual report when the colony does not exist', async () => { + prisma.colony.findUnique.mockResolvedValueOnce(null); + await expect( + service.exportReportPdf('colony-annual-report', { colonyId: 'missing' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('renders a PDF buffer for the colony annual report', async () => { + prisma.colony.findUnique.mockResolvedValueOnce({ + id: 'c1', + code: 'V-1', + colonyType: 'PRODUCTION_COLONY', + status: 'ACTIVE', + apiary: { id: 'a1', name: 'Stand Ost' }, + }); + + const pdf = await service.exportReportPdf('colony-annual-report', { + colonyId: 'c1', + year: '2026', + }); + expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF'); + }); + + it('rejects the apiary annual report without an apiaryId', async () => { + await expect( + service.exportReportPdf('apiary-annual-report'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('renders a PDF buffer for the apiary annual report', async () => { + prisma.apiary.findUnique.mockResolvedValueOnce({ + id: 'a1', + name: 'Stand Ost', + }); + prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-1' }]); + + const pdf = await service.exportReportPdf('apiary-annual-report', { + apiaryId: 'a1', + }); expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF'); }); }); diff --git a/ApiCore-Backend/src/export/export.service.ts b/ApiCore-Backend/src/export/export.service.ts index 80f21db..c35a97e 100644 --- a/ApiCore-Backend/src/export/export.service.ts +++ b/ApiCore-Backend/src/export/export.service.ts @@ -2,6 +2,12 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import PDFDocument from 'pdfkit'; import { PrismaService } from '../prisma/prisma.service'; import { HoneyBatchesService } from '../honey-batches/honey-batches.service'; +import { + buildApiaryAnnualReport, + buildColonyAnnualReport, + renderApiaryAnnualReportPdf, + renderColonyAnnualReportPdf, +} from './annual-reports'; const JSON_ENTITIES = [ 'apiaries', @@ -12,9 +18,31 @@ const JSON_ENTITIES = [ ] as const; type JsonEntity = (typeof JSON_ENTITIES)[number]; -const PDF_REPORTS = ['honey-batch-traceability'] as const; +const PDF_REPORTS = [ + 'honey-batch-traceability', + 'colony-annual-report', + 'apiary-annual-report', +] as const; type PdfReport = (typeof PDF_REPORTS)[number]; +export interface ExportReportOptions { + honeyBatchId?: string; + colonyId?: string; + apiaryId?: string; + year?: string; +} + +function resolveYear(year: string | undefined): number { + if (!year) { + return new Date().getUTCFullYear(); + } + const parsed = Number(year); + if (!Number.isInteger(parsed)) { + throw new BadRequestException('Query parameter year must be an integer'); + } + return parsed; +} + function isJsonEntity(value: string): value is JsonEntity { return (JSON_ENTITIES as readonly string[]).includes(value); } @@ -117,18 +145,50 @@ export class ExportService { async exportReportPdf( report: string, - honeyBatchId?: string, + options: ExportReportOptions = {}, ): Promise { if (!isPdfReport(report)) { throw new BadRequestException(`Unsupported export report: ${report}`); } - if (!honeyBatchId) { - throw new BadRequestException( - 'Query parameter honeyBatchId is required for this report', - ); + switch (report) { + case 'honey-batch-traceability': { + if (!options.honeyBatchId) { + throw new BadRequestException( + 'Query parameter honeyBatchId is required for this report', + ); + } + const trace = await this.honeyBatchesService.traceability( + options.honeyBatchId, + ); + return this.renderTraceabilityPdf(trace); + } + case 'colony-annual-report': { + if (!options.colonyId) { + throw new BadRequestException( + 'Query parameter colonyId is required for this report', + ); + } + const colonyReport = await buildColonyAnnualReport( + this.prisma, + options.colonyId, + resolveYear(options.year), + ); + return renderColonyAnnualReportPdf(colonyReport); + } + case 'apiary-annual-report': { + if (!options.apiaryId) { + throw new BadRequestException( + 'Query parameter apiaryId is required for this report', + ); + } + const apiaryReport = await buildApiaryAnnualReport( + this.prisma, + options.apiaryId, + resolveYear(options.year), + ); + return renderApiaryAnnualReportPdf(apiaryReport); + } } - const trace = await this.honeyBatchesService.traceability(honeyBatchId); - return this.renderTraceabilityPdf(trace); } private renderTraceabilityPdf(trace: TraceabilityReport): Promise { diff --git a/ApiCore-Backend/test/export.e2e-spec.ts b/ApiCore-Backend/test/export.e2e-spec.ts index 6986a4e..423cf33 100644 --- a/ApiCore-Backend/test/export.e2e-spec.ts +++ b/ApiCore-Backend/test/export.e2e-spec.ts @@ -131,4 +131,47 @@ describe('Export (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(400); }); + + it('rejects the colony annual report without a colonyId', () => { + return request(app.getHttpServer()) + .get('/api/v1/export/colony-annual-report.pdf') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(400); + }); + + it('rejects the colony annual report for an unknown colony', () => { + return request(app.getHttpServer()) + .get('/api/v1/export/colony-annual-report.pdf?colonyId=does-not-exist') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(404); + }); + + it('renders a colony annual report PDF', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/export/colony-annual-report.pdf?colonyId=${colonyId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect(response.headers['content-type']).toContain('application/pdf'); + expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe( + '%PDF', + ); + }); + + it('rejects the apiary annual report without an apiaryId', () => { + return request(app.getHttpServer()) + .get('/api/v1/export/apiary-annual-report.pdf') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(400); + }); + + it('renders an apiary annual report PDF', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/export/apiary-annual-report.pdf?apiaryId=${apiaryId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect(response.headers['content-type']).toContain('application/pdf'); + expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe( + '%PDF', + ); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx index 961dbb1..b42c0b5 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx @@ -2,13 +2,16 @@ import Link from "next/link"; import { useParams } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { fetchApiary } from "@/lib/apiaries/api"; +import { exportApiaryAnnualReportPdf } from "@/lib/export/api"; import { QrCodeDialog } from "@/components/qr-code-dialog"; import { DocumentUploadCard } from "@/components/document-upload-card"; import { Button } from "@/components/ui/button"; import { ApiaryEditCard } from "./apiary-edit-card"; +const CURRENT_YEAR = new Date().getFullYear(); + export default function ApiaryDetailPage() { const params = useParams<{ id: string }>(); const { data: apiary, isLoading, isError } = useQuery({ @@ -16,6 +19,10 @@ export default function ApiaryDetailPage() { queryFn: () => fetchApiary(params.id), }); + const exportReportMutation = useMutation({ + mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR), + }); + if (isLoading) { return

Lädt…

; } @@ -34,13 +41,23 @@ export default function ApiaryDetailPage() { - +
+ + +
); } diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx index 01d791e..ea12e7d 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx @@ -1,10 +1,12 @@ "use client"; import { useParams } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { fetchColony } from "@/lib/colonies/api"; +import { exportColonyAnnualReportPdf } from "@/lib/export/api"; import { QrCodeDialog } from "@/components/qr-code-dialog"; import { DocumentUploadCard } from "@/components/document-upload-card"; +import { Button } from "@/components/ui/button"; import { ColonyEditCard } from "./colony-edit-card"; import { ColonyActions } from "./colony-actions"; import { ColonyHistoryCard } from "./colony-history"; @@ -14,6 +16,8 @@ import { ColonySwarmControlCard } from "./colony-swarm-control"; import { ColonyTreatmentsCard } from "./colony-treatments"; import { ColonyFeedingsCard } from "./colony-feedings"; +const CURRENT_YEAR = new Date().getFullYear(); + export default function ColonyDetailPage() { const params = useParams<{ id: string }>(); const { data: colony, isLoading, isError } = useQuery({ @@ -21,6 +25,10 @@ export default function ColonyDetailPage() { queryFn: () => fetchColony(params.id), }); + const exportReportMutation = useMutation({ + mutationFn: () => exportColonyAnnualReportPdf(params.id, CURRENT_YEAR), + }); + if (isLoading) { return

Lädt…

; } @@ -34,7 +42,16 @@ export default function ColonyDetailPage() { return (
-
+
+
diff --git a/ApiCore-Frontend/src/lib/export/api.ts b/ApiCore-Frontend/src/lib/export/api.ts index 17850c1..b9e4b8a 100644 --- a/ApiCore-Frontend/src/lib/export/api.ts +++ b/ApiCore-Frontend/src/lib/export/api.ts @@ -31,3 +31,19 @@ export async function exportHoneyBatchTraceabilityPdf(honeyBatchId: string): Pro ); downloadBlob(blob, filename); } + +export async function exportColonyAnnualReportPdf(colonyId: string, year: number): Promise { + const { blob, filename } = await apiFetchBlob( + `/export/colony-annual-report.pdf?colonyId=${colonyId}&year=${year}`, + `jahresbericht-volk-${year}.pdf`, + ); + downloadBlob(blob, filename); +} + +export async function exportApiaryAnnualReportPdf(apiaryId: string, year: number): Promise { + const { blob, filename } = await apiFetchBlob( + `/export/apiary-annual-report.pdf?apiaryId=${apiaryId}&year=${year}`, + `jahresbericht-stand-${year}.pdf`, + ); + downloadBlob(blob, filename); +}