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.
This commit is contained in:
Nick Szaunig committed 2026-08-25 11:51:41 +02:00
1 parent f1298eff08
commit 3f7c4ed404
8 files changed
+630 -26

No files matched your search

@@ -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<string, string> = {
NONE: 'Keine',
LOW: 'Gering',
HIGH: 'Hoch',
};
const SWARM_ACTION_LABELS: Record<string, string> = {
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<ColonyAnnualReport> {
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<ApiaryAnnualReport> {
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<Buffer> {
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<Buffer> {
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();
});
}
@@ -46,9 +46,17 @@ export class ExportController {
async reportPdf( async reportPdf(
@Param('report') report: string, @Param('report') report: string,
@Query('honeyBatchId') honeyBatchId: string | undefined, @Query('honeyBatchId') honeyBatchId: string | undefined,
@Query('colonyId') colonyId: string | undefined,
@Query('apiaryId') apiaryId: string | undefined,
@Query('year') year: string | undefined,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
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-Type', 'application/pdf');
res.setHeader( res.setHeader(
'Content-Disposition', 'Content-Disposition',
@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service'; import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
@@ -6,21 +6,42 @@ import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
describe('ExportService', () => { describe('ExportService', () => {
let service: ExportService; let service: ExportService;
let prisma: { let prisma: {
colony: { findMany: jest.Mock }; colony: { findMany: jest.Mock; findUnique: jest.Mock };
honeyBatch: { findMany: jest.Mock }; honeyBatch: { findMany: jest.Mock };
apiary: { findMany: jest.Mock }; apiary: { findMany: jest.Mock; findUnique: jest.Mock };
queen: { findMany: jest.Mock }; queen: { findMany: jest.Mock };
task: { 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 }; let honeyBatchesService: { traceability: jest.Mock };
beforeEach(() => { beforeEach(() => {
prisma = { prisma = {
colony: { findMany: jest.fn() }, colony: { findMany: jest.fn(), findUnique: jest.fn() },
honeyBatch: { findMany: jest.fn() }, honeyBatch: { findMany: jest.fn() },
apiary: { findMany: jest.fn() }, apiary: { findMany: jest.fn(), findUnique: jest.fn() },
queen: { findMany: jest.fn() }, queen: { findMany: jest.fn() },
task: { 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() }; honeyBatchesService = { traceability: jest.fn() };
service = new ExportService( service = new ExportService(
@@ -85,7 +106,7 @@ describe('ExportService', () => {
it('rejects an unsupported PDF report', async () => { it('rejects an unsupported PDF report', async () => {
await expect( await expect(
service.exportReportPdf('unknown', 'honey-batch-1'), service.exportReportPdf('unknown', { honeyBatchId: 'honey-batch-1' }),
).rejects.toBeInstanceOf(BadRequestException); ).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'); expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
}); });
}); });
+68 -8
View File
@@ -2,6 +2,12 @@ import { Injectable, BadRequestException } from '@nestjs/common';
import PDFDocument from 'pdfkit'; import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service'; import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
import {
buildApiaryAnnualReport,
buildColonyAnnualReport,
renderApiaryAnnualReportPdf,
renderColonyAnnualReportPdf,
} from './annual-reports';
const JSON_ENTITIES = [ const JSON_ENTITIES = [
'apiaries', 'apiaries',
@@ -12,9 +18,31 @@ const JSON_ENTITIES = [
] as const; ] as const;
type JsonEntity = (typeof JSON_ENTITIES)[number]; 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]; 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 { function isJsonEntity(value: string): value is JsonEntity {
return (JSON_ENTITIES as readonly string[]).includes(value); return (JSON_ENTITIES as readonly string[]).includes(value);
} }
@@ -117,18 +145,50 @@ export class ExportService {
async exportReportPdf( async exportReportPdf(
report: string, report: string,
honeyBatchId?: string, options: ExportReportOptions = {},
): Promise<Buffer> { ): Promise<Buffer> {
if (!isPdfReport(report)) { if (!isPdfReport(report)) {
throw new BadRequestException(`Unsupported export report: ${report}`); throw new BadRequestException(`Unsupported export report: ${report}`);
} }
if (!honeyBatchId) { switch (report) {
throw new BadRequestException( case 'honey-batch-traceability': {
'Query parameter honeyBatchId is required for this report', 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<Buffer> { private renderTraceabilityPdf(trace: TraceabilityReport): Promise<Buffer> {
+43
View File
@@ -131,4 +131,47 @@ describe('Export (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`) .set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400); .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',
);
});
}); });
@@ -2,13 +2,16 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; 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 { fetchApiary } from "@/lib/apiaries/api";
import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card"; import { DocumentUploadCard } from "@/components/document-upload-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ApiaryEditCard } from "./apiary-edit-card"; import { ApiaryEditCard } from "./apiary-edit-card";
const CURRENT_YEAR = new Date().getFullYear();
export default function ApiaryDetailPage() { export default function ApiaryDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { data: apiary, isLoading, isError } = useQuery({ const { data: apiary, isLoading, isError } = useQuery({
@@ -16,6 +19,10 @@ export default function ApiaryDetailPage() {
queryFn: () => fetchApiary(params.id), queryFn: () => fetchApiary(params.id),
}); });
const exportReportMutation = useMutation({
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
});
if (isLoading) { if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>; return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
} }
@@ -34,13 +41,23 @@ export default function ApiaryDetailPage() {
</div> </div>
<ApiaryEditCard key={apiary.id} apiary={apiary} /> <ApiaryEditCard key={apiary.id} apiary={apiary} />
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} /> <DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
<Button <div className="flex flex-wrap gap-2">
className="w-fit" <Button
nativeButton={false} nativeButton={false}
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />} render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
> >
Neue Ernte Neue Ernte
</Button> </Button>
<Button
variant="outline"
disabled={exportReportMutation.isPending}
onClick={() => exportReportMutation.mutate()}
>
{exportReportMutation.isPending
? "Wird erstellt…"
: `Jahresbericht ${CURRENT_YEAR}`}
</Button>
</div>
</main> </main>
); );
} }
@@ -1,10 +1,12 @@
"use client"; "use client";
import { useParams } from "next/navigation"; 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 { fetchColony } from "@/lib/colonies/api";
import { exportColonyAnnualReportPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card"; import { DocumentUploadCard } from "@/components/document-upload-card";
import { Button } from "@/components/ui/button";
import { ColonyEditCard } from "./colony-edit-card"; import { ColonyEditCard } from "./colony-edit-card";
import { ColonyActions } from "./colony-actions"; import { ColonyActions } from "./colony-actions";
import { ColonyHistoryCard } from "./colony-history"; import { ColonyHistoryCard } from "./colony-history";
@@ -14,6 +16,8 @@ import { ColonySwarmControlCard } from "./colony-swarm-control";
import { ColonyTreatmentsCard } from "./colony-treatments"; import { ColonyTreatmentsCard } from "./colony-treatments";
import { ColonyFeedingsCard } from "./colony-feedings"; import { ColonyFeedingsCard } from "./colony-feedings";
const CURRENT_YEAR = new Date().getFullYear();
export default function ColonyDetailPage() { export default function ColonyDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { data: colony, isLoading, isError } = useQuery({ const { data: colony, isLoading, isError } = useQuery({
@@ -21,6 +25,10 @@ export default function ColonyDetailPage() {
queryFn: () => fetchColony(params.id), queryFn: () => fetchColony(params.id),
}); });
const exportReportMutation = useMutation({
mutationFn: () => exportColonyAnnualReportPdf(params.id, CURRENT_YEAR),
});
if (isLoading) { if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>; return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
} }
@@ -34,7 +42,16 @@ export default function ColonyDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex justify-end"> <div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
disabled={exportReportMutation.isPending}
onClick={() => exportReportMutation.mutate()}
>
{exportReportMutation.isPending
? "Wird erstellt…"
: `Jahresbericht ${CURRENT_YEAR}`}
</Button>
<QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für Durchsicht" /> <QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für Durchsicht" />
</div> </div>
<ColonyEditCard key={colony.id} colony={colony} /> <ColonyEditCard key={colony.id} colony={colony} />
+16
View File
@@ -31,3 +31,19 @@ export async function exportHoneyBatchTraceabilityPdf(honeyBatchId: string): Pro
); );
downloadBlob(blob, filename); downloadBlob(blob, filename);
} }
export async function exportColonyAnnualReportPdf(colonyId: string, year: number): Promise<void> {
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<void> {
const { blob, filename } = await apiFetchBlob(
`/export/apiary-annual-report.pdf?apiaryId=${apiaryId}&year=${year}`,
`jahresbericht-stand-${year}.pdf`,
);
downloadBlob(blob, filename);
}