feat: Bearbeiten für alle Log-Einträge (Aufgaben, Durchsichten, Behandlungen, Fütterungen, Varroa-Messungen, Ernten, Schleuderungen, Abfüllungen)

Bisher liessen sich diese acht Objekttypen nur anlegen, nie nachträglich
korrigieren – Aufgaben hatten zwar schon einen PATCH-Endpunkt, aber
keine Bearbeiten-UI, die übrigen sieben hatten weder Backend- noch
Frontend-Unterstützung. Jetzt bekommt jeder Typ einen PATCH :id
Endpunkt (UpdateXDto per PartialType(PickType(...)) nach demselben
Muster wie UpdateColonyDto/UpdateApiaryDto, mit Audit-Log-Eintrag
inkl. Vorher-/Nachher-Stand) sowie eine Bearbeiten-UI:

- Aufgaben: Dialog auf der Aufgabenliste
- Durchsichten/Behandlungen/Fütterungen/Varroa-Messungen: Dialog pro
  Listeneintrag auf der Volk-Detailseite
- Ernten: Dialog auf der eigenen Ernte-Detailseite
- Schleuderungen: Dialog direkt in der Rückverfolgbarkeits-Karte der
  Honigcharge (keine eigene Detailseite nötig); Erntezeilen dort
  verlinken jetzt zur Ernte-Detailseite statt sie zu duplizieren
- Abfüllungen: Dialog auf der Abfüllungs-Detailseite, bewusst nur
  Datum/MHD/Losnummer editierbar – Glasgröße/-anzahl bleiben
  unveränderlich, weil sie bereits unumkehrbar die Honigcharge-Menge
  reduziert haben

Relationsstiftende Fremdschlüssel (colonyId, apiaryId/colonyIds,
harvestIds, honeyBatchId) bleiben überall bewusst nicht editierbar,
konsistent mit dem bestehenden apiaryId-Ausschluss bei UpdateColonyDto.
This commit is contained in:
Nick Szaunig committed 2026-08-25 04:28:37 +02:00
1 parent 63ab0e842d
commit 68f459001a
58 files changed
+1909 -60

No files matched your search

@@ -1,10 +1,19 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma'; import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
@ApiTags('bottling') @ApiTags('bottling')
@@ -31,4 +40,14 @@ export class BottlingController {
) { ) {
return this.bottlingService.create(dto, actor.id); return this.bottlingService.create(dto, actor.id);
} }
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateBottlingBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.bottlingService.update(id, dto, actor.id);
}
} }
@@ -13,6 +13,7 @@ describe('BottlingService', () => {
findUnique: jest.Mock; findUnique: jest.Mock;
findMany: jest.Mock; findMany: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock;
}; };
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
@@ -34,6 +35,7 @@ describe('BottlingService', () => {
findUnique: jest.fn(), findUnique: jest.fn(),
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
}, },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx), callback(tx),
@@ -124,4 +126,35 @@ describe('BottlingService', () => {
}), }),
); );
}); });
it('update writes an audit log entry with before and after data', async () => {
const before = { id: 'b1', lotNumber: 'L-1' };
const updated = { id: 'b1', lotNumber: 'L-2' };
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(updated);
prisma.bottlingBatch.update.mockResolvedValue(updated);
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(result).toEqual(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'UPDATE',
beforeData: before,
afterData: updated,
}),
);
});
it('rejects updating to a product that does not exist', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
prisma.product.findUnique.mockResolvedValueOnce(null);
await expect(
service.update('b1', { productId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
}); });
@@ -8,6 +8,7 @@ import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { BottlingBatch } from '../../generated/prisma'; import { BottlingBatch } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
const bottlingBatchInclude = { const bottlingBatchInclude = {
honeyBatch: true, honeyBatch: true,
@@ -109,4 +110,36 @@ export class BottlingService {
await this.webhooks.dispatch('batch.updated', created.honeyBatch); await this.webhooks.dispatch('batch.updated', created.honeyBatch);
return created; return created;
} }
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
const before = await this.findById(id);
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
await this.prisma.bottlingBatch.update({
where: { id },
data: {
...dto,
date: dto.date ? new Date(dto.date) : undefined,
bestBeforeDate: dto.bestBeforeDate
? new Date(dto.bestBeforeDate)
: undefined,
},
});
const bottlingBatch = await this.findById(id);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: bottlingBatch,
});
return bottlingBatch;
}
} }
@@ -0,0 +1,11 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateBottlingBatchDto } from './create-bottling-batch.dto';
export class UpdateBottlingBatchDto extends PartialType(
PickType(CreateBottlingBatchDto, [
'productId',
'date',
'bestBeforeDate',
'lotNumber',
] as const),
) {}
@@ -0,0 +1,13 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateExtractionDto } from './create-extraction.dto';
export class UpdateExtractionDto extends PartialType(
PickType(CreateExtractionDto, [
'date',
'amount',
'room',
'waterContent',
'temperature',
'notes',
] as const),
) {}
@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma'; import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateExtractionDto } from './dto/create-extraction.dto'; import { CreateExtractionDto } from './dto/create-extraction.dto';
import { UpdateExtractionDto } from './dto/update-extraction.dto';
import { ExtractionsService } from './extractions.service'; import { ExtractionsService } from './extractions.service';
@ApiTags('extractions') @ApiTags('extractions')
@@ -31,4 +32,14 @@ export class ExtractionsController {
) { ) {
return this.extractionsService.create(dto, actor.id); return this.extractionsService.create(dto, actor.id);
} }
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateExtractionDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.extractionsService.update(id, dto, actor.id);
}
} }
@@ -6,7 +6,7 @@ import { AuditLogService } from '../audit/audit-log.service';
describe('ExtractionsService', () => { describe('ExtractionsService', () => {
let service: ExtractionsService; let service: ExtractionsService;
let prisma: { let prisma: {
extraction: { findUnique: jest.Mock }; extraction: { findUnique: jest.Mock; update: jest.Mock };
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
@@ -23,7 +23,7 @@ describe('ExtractionsService', () => {
extractionHarvest: { createMany: jest.fn() }, extractionHarvest: { createMany: jest.fn() },
}; };
prisma = { prisma = {
extraction: { findUnique: jest.fn() }, extraction: { findUnique: jest.fn(), update: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx), callback(tx),
), ),
@@ -58,4 +58,29 @@ describe('ExtractionsService', () => {
{ extractionId: extraction.id, harvestId: 'harvest-2' }, { extractionId: extraction.id, harvestId: 'harvest-2' },
]); ]);
}); });
it('update writes an audit log entry with before and after data', async () => {
const updated = { ...extraction, notes: 'geändert' };
prisma.extraction.findUnique
.mockResolvedValueOnce(extraction)
.mockResolvedValueOnce(updated);
prisma.extraction.update.mockResolvedValue(updated);
const result = await service.update(
'extraction-1',
{ notes: 'geändert' },
'user-1',
);
expect(result).toEqual(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Extraction',
entityId: 'extraction-1',
action: 'UPDATE',
beforeData: extraction,
afterData: updated,
}),
);
});
}); });
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { CreateExtractionDto } from './dto/create-extraction.dto'; import { CreateExtractionDto } from './dto/create-extraction.dto';
import { UpdateExtractionDto } from './dto/update-extraction.dto';
const extractionInclude = { const extractionInclude = {
harvests: { harvests: {
@@ -66,4 +67,22 @@ export class ExtractionsService {
}); });
return this.findById(extraction.id); return this.findById(extraction.id);
} }
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.extraction.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
const extraction = await this.findById(id);
await this.auditLog.record({
entityType: 'Extraction',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: extraction,
});
return extraction;
}
} }
@@ -0,0 +1,14 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateFeedingDto } from './create-feeding.dto';
export class UpdateFeedingDto extends PartialType(
PickType(CreateFeedingDto, [
'date',
'feedType',
'product',
'amount',
'unit',
'batchNumber',
'notes',
] as const),
) {}
@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma'; import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateFeedingDto } from './dto/create-feeding.dto'; import { CreateFeedingDto } from './dto/create-feeding.dto';
import { UpdateFeedingDto } from './dto/update-feeding.dto';
import { FeedingsService } from './feedings.service'; import { FeedingsService } from './feedings.service';
@ApiTags('feedings') @ApiTags('feedings')
@@ -31,4 +32,14 @@ export class FeedingsController {
) { ) {
return this.feedingsService.create(dto, actor.id); return this.feedingsService.create(dto, actor.id);
} }
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateFeedingDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.feedingsService.update(id, dto, actor.id);
}
} }
@@ -10,6 +10,7 @@ describe('FeedingsService', () => {
findMany: jest.Mock; findMany: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock;
groupBy: jest.Mock; groupBy: jest.Mock;
}; };
}; };
@@ -23,6 +24,7 @@ describe('FeedingsService', () => {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
groupBy: jest.fn(), groupBy: jest.fn(),
}, },
}; };
@@ -61,6 +63,29 @@ describe('FeedingsService', () => {
expect(createArg.data.userId).toBe('user-1'); expect(createArg.data.userId).toBe('user-1');
}); });
it('update writes an audit log entry with before and after data', async () => {
prisma.feeding.findUnique.mockResolvedValue(feeding);
const updated = { ...feeding, notes: 'geändert' };
prisma.feeding.update.mockResolvedValue(updated);
const result = await service.update(
'feeding-1',
{ notes: 'geändert' },
'user-1',
);
expect(result).toBe(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Feeding',
entityId: 'feeding-1',
action: 'UPDATE',
beforeData: feeding,
afterData: updated,
}),
);
});
it('summaryByColony sums amounts per unit', async () => { it('summaryByColony sums amounts per unit', async () => {
prisma.feeding.groupBy.mockResolvedValue([ prisma.feeding.groupBy.mockResolvedValue([
{ unit: 'l', _sum: { amount: 5.5 } }, { unit: 'l', _sum: { amount: 5.5 } },
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { Feeding } from '../../generated/prisma'; import { Feeding } from '../../generated/prisma';
import { CreateFeedingDto } from './dto/create-feeding.dto'; import { CreateFeedingDto } from './dto/create-feeding.dto';
import { UpdateFeedingDto } from './dto/update-feeding.dto';
export interface FeedingSummaryEntry { export interface FeedingSummaryEntry {
unit: string; unit: string;
@@ -60,4 +61,25 @@ export class FeedingsService {
}); });
return feeding; return feeding;
} }
async update(
id: string,
dto: UpdateFeedingDto,
actingUserId: string,
): Promise<Feeding> {
const before = await this.findById(id);
const feeding = await this.prisma.feeding.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record({
entityType: 'Feeding',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: feeding,
});
return feeding;
}
} }
@@ -0,0 +1,13 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateHarvestDto } from './create-harvest.dto';
export class UpdateHarvestDto extends PartialType(
PickType(CreateHarvestDto, [
'date',
'honeyType',
'honeySuperCount',
'grossWeight',
'netWeight',
'notes',
] as const),
) {}
@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma'; import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHarvestDto } from './dto/create-harvest.dto'; import { CreateHarvestDto } from './dto/create-harvest.dto';
import { UpdateHarvestDto } from './dto/update-harvest.dto';
import { HarvestsService } from './harvests.service'; import { HarvestsService } from './harvests.service';
@ApiTags('harvests') @ApiTags('harvests')
@@ -31,4 +32,14 @@ export class HarvestsController {
) { ) {
return this.harvestsService.create(dto, actor.id); return this.harvestsService.create(dto, actor.id);
} }
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateHarvestDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.harvestsService.update(id, dto, actor.id);
}
} }
@@ -7,7 +7,7 @@ import { WebhooksService } from '../webhooks/webhooks.service';
describe('HarvestsService', () => { describe('HarvestsService', () => {
let service: HarvestsService; let service: HarvestsService;
let prisma: { let prisma: {
harvest: { findUnique: jest.Mock }; harvest: { findUnique: jest.Mock; update: jest.Mock };
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
@@ -24,7 +24,7 @@ describe('HarvestsService', () => {
harvestColony: { createMany: jest.fn() }, harvestColony: { createMany: jest.fn() },
}; };
prisma = { prisma = {
harvest: { findUnique: jest.fn() }, harvest: { findUnique: jest.fn(), update: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx), callback(tx),
), ),
@@ -65,4 +65,29 @@ describe('HarvestsService', () => {
{ harvestId: harvest.id, colonyId: 'colony-2' }, { harvestId: harvest.id, colonyId: 'colony-2' },
]); ]);
}); });
it('update writes an audit log entry with before and after data', async () => {
const updated = { ...harvest, notes: 'geändert' };
prisma.harvest.findUnique
.mockResolvedValueOnce(harvest)
.mockResolvedValueOnce(updated);
prisma.harvest.update.mockResolvedValue(updated);
const result = await service.update(
'harvest-1',
{ notes: 'geändert' },
'user-1',
);
expect(result).toEqual(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Harvest',
entityId: 'harvest-1',
action: 'UPDATE',
beforeData: harvest,
afterData: updated,
}),
);
});
}); });
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { CreateHarvestDto } from './dto/create-harvest.dto'; import { CreateHarvestDto } from './dto/create-harvest.dto';
import { UpdateHarvestDto } from './dto/update-harvest.dto';
const harvestInclude = { const harvestInclude = {
colonies: { include: { colony: true } }, colonies: { include: { colony: true } },
@@ -67,4 +68,22 @@ export class HarvestsService {
await this.webhooks.dispatch('harvest.created', created); await this.webhooks.dispatch('harvest.created', created);
return created; return created;
} }
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.harvest.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
const harvest = await this.findById(id);
await this.auditLog.record({
entityType: 'Harvest',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: harvest,
});
return harvest;
}
} }
@@ -0,0 +1,27 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateInspectionDto } from './create-inspection.dto';
export class UpdateInspectionDto extends PartialType(
PickType(CreateInspectionDto, [
'date',
'weather',
'temperature',
'colonyStrength',
'broodAmount',
'broodPattern',
'openBroodPresent',
'cappedBroodPresent',
'eggsPresent',
'queenSeen',
'queenCellsPresent',
'swarmMood',
'gentleness',
'combBehavior',
'feedStatus',
'honeySuperStatus',
'healthIssues',
'varroaIssues',
'measures',
'notes',
] as const),
) {}
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma'; import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
@@ -6,6 +6,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateInspectionDto } from './dto/create-inspection.dto';
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto';
import { InspectionsService } from './inspections.service'; import { InspectionsService } from './inspections.service';
@ApiTags('inspections') @ApiTags('inspections')
@@ -33,6 +34,16 @@ export class InspectionsController {
return this.inspectionsService.create(dto, actor.id); return this.inspectionsService.create(dto, actor.id);
} }
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateInspectionDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.inspectionsService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF) @Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Post(':id/create-task') @Post(':id/create-task')
createTask( createTask(
@@ -3,6 +3,7 @@ import { InspectionsService } from './inspections.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('InspectionsService', () => { describe('InspectionsService', () => {
let service: InspectionsService; let service: InspectionsService;
@@ -11,9 +12,11 @@ describe('InspectionsService', () => {
findMany: jest.Mock; findMany: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock;
}; };
task: { create: jest.Mock }; task: { create: jest.Mock };
}; };
let auditLog: { record: jest.Mock };
const inspection = { const inspection = {
id: 'inspection-1', id: 'inspection-1',
@@ -27,6 +30,7 @@ describe('InspectionsService', () => {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
}, },
task: { create: jest.fn() }, task: { create: jest.fn() },
}; };
@@ -34,10 +38,12 @@ describe('InspectionsService', () => {
const automation = { const automation = {
evaluateInspection: jest.fn().mockResolvedValue(undefined), evaluateInspection: jest.fn().mockResolvedValue(undefined),
}; };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new InspectionsService( service = new InspectionsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
webhooks as unknown as WebhooksService, webhooks as unknown as WebhooksService,
automation as unknown as AutomationService, automation as unknown as AutomationService,
auditLog as unknown as AuditLogService,
); );
}); });
@@ -63,6 +69,29 @@ describe('InspectionsService', () => {
expect(createArg.data.userId).toBe('user-1'); expect(createArg.data.userId).toBe('user-1');
}); });
it('update writes an audit log entry with before and after data', async () => {
prisma.inspection.findUnique.mockResolvedValue(inspection);
const updated = { ...inspection, notes: 'geändert' };
prisma.inspection.update.mockResolvedValue(updated);
const result = await service.update(
'inspection-1',
{ notes: 'geändert' },
'user-1',
);
expect(result).toBe(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Inspection',
entityId: 'inspection-1',
action: 'UPDATE',
beforeData: inspection,
afterData: updated,
}),
);
});
it('createTaskFromInspection links the task to the inspection and its colony', async () => { it('createTaskFromInspection links the task to the inspection and its colony', async () => {
prisma.inspection.findUnique.mockResolvedValue(inspection); prisma.inspection.findUnique.mockResolvedValue(inspection);
prisma.task.create.mockResolvedValue({ id: 'task-1' }); prisma.task.create.mockResolvedValue({ id: 'task-1' });
@@ -2,9 +2,11 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Inspection, Task } from '../../generated/prisma'; import { Inspection, Task } from '../../generated/prisma';
import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateInspectionDto } from './dto/create-inspection.dto';
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto';
@Injectable() @Injectable()
export class InspectionsService { export class InspectionsService {
@@ -12,6 +14,7 @@ export class InspectionsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly automation: AutomationService, private readonly automation: AutomationService,
private readonly auditLog: AuditLogService,
) {} ) {}
async findAll(): Promise<Inspection[]> { async findAll(): Promise<Inspection[]> {
@@ -47,6 +50,27 @@ export class InspectionsService {
return inspection; return inspection;
} }
async update(
id: string,
dto: UpdateInspectionDto,
actingUserId: string,
): Promise<Inspection> {
const before = await this.findById(id);
const inspection = await this.prisma.inspection.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record({
entityType: 'Inspection',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: inspection,
});
return inspection;
}
async createTaskFromInspection( async createTaskFromInspection(
id: string, id: string,
dto: CreateTaskFromInspectionDto, dto: CreateTaskFromInspectionDto,
@@ -0,0 +1,17 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateTreatmentDto } from './create-treatment.dto';
export class UpdateTreatmentDto extends PartialType(
PickType(CreateTreatmentDto, [
'date',
'startDate',
'endDate',
'product',
'activeIngredient',
'amount',
'method',
'reason',
'batchNumber',
'notes',
] as const),
) {}
@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma'; import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
import { TreatmentsService } from './treatments.service'; import { TreatmentsService } from './treatments.service';
@ApiTags('treatments') @ApiTags('treatments')
@@ -31,4 +32,14 @@ export class TreatmentsController {
) { ) {
return this.treatmentsService.create(dto, actor.id); return this.treatmentsService.create(dto, actor.id);
} }
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateTreatmentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.treatmentsService.update(id, dto, actor.id);
}
} }
@@ -10,6 +10,7 @@ describe('TreatmentsService', () => {
findMany: jest.Mock; findMany: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock;
}; };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
@@ -26,6 +27,7 @@ describe('TreatmentsService', () => {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
}, },
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
@@ -58,6 +60,29 @@ describe('TreatmentsService', () => {
expect(createArg.data.product).toBe('Ameisensäure'); expect(createArg.data.product).toBe('Ameisensäure');
}); });
it('update writes an audit log entry with before and after data', async () => {
prisma.treatment.findUnique.mockResolvedValue(treatment);
const updated = { ...treatment, notes: 'geändert' };
prisma.treatment.update.mockResolvedValue(updated);
const result = await service.update(
'treatment-1',
{ notes: 'geändert' },
'user-1',
);
expect(result).toBe(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Treatment',
entityId: 'treatment-1',
action: 'UPDATE',
beforeData: treatment,
afterData: updated,
}),
);
});
it('findByColony orders results chronologically', async () => { it('findByColony orders results chronologically', async () => {
prisma.treatment.findMany.mockResolvedValue([treatment]); prisma.treatment.findMany.mockResolvedValue([treatment]);
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { Treatment } from '../../generated/prisma'; import { Treatment } from '../../generated/prisma';
import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
@Injectable() @Injectable()
export class TreatmentsService { export class TreatmentsService {
@@ -52,4 +53,30 @@ export class TreatmentsService {
}); });
return treatment; return treatment;
} }
async update(
id: string,
dto: UpdateTreatmentDto,
actingUserId: string,
): Promise<Treatment> {
const before = await this.findById(id);
const treatment = await this.prisma.treatment.update({
where: { id },
data: {
...dto,
date: dto.date ? new Date(dto.date) : undefined,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
},
});
await this.auditLog.record({
entityType: 'Treatment',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: treatment,
});
return treatment;
}
} }
@@ -0,0 +1,14 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
export class UpdateVarroaMeasurementDto extends PartialType(
PickType(CreateVarroaMeasurementDto, [
'date',
'method',
'durationMinutes',
'miteCount',
'calculatedValue',
'assessment',
'notes',
] as const),
) {}
@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma'; import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
import { VarroaService } from './varroa.service'; import { VarroaService } from './varroa.service';
@ApiTags('varroa-measurements') @ApiTags('varroa-measurements')
@@ -31,4 +32,14 @@ export class VarroaController {
) { ) {
return this.varroaService.create(dto, actor.id); return this.varroaService.create(dto, actor.id);
} }
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateVarroaMeasurementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.varroaService.update(id, dto, actor.id);
}
} }
@@ -11,9 +11,11 @@ describe('VarroaService', () => {
findMany: jest.Mock; findMany: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock;
}; };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let automation: { evaluateVarroaMeasurement: jest.Mock };
const measurement = { const measurement = {
id: 'varroa-1', id: 'varroa-1',
@@ -27,10 +29,11 @@ describe('VarroaService', () => {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
}, },
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const automation = { automation = {
evaluateVarroaMeasurement: jest.fn().mockResolvedValue(undefined), evaluateVarroaMeasurement: jest.fn().mockResolvedValue(undefined),
}; };
service = new VarroaService( service = new VarroaService(
@@ -62,6 +65,30 @@ describe('VarroaService', () => {
expect(createArg.data.userId).toBe('user-1'); expect(createArg.data.userId).toBe('user-1');
}); });
it('update writes an audit log entry and re-evaluates automation', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue(measurement);
const updated = { ...measurement, miteCount: 12 };
prisma.varroaMeasurement.update.mockResolvedValue(updated);
const result = await service.update(
'varroa-1',
{ miteCount: 12 },
'user-1',
);
expect(result).toBe(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'VarroaMeasurement',
entityId: 'varroa-1',
action: 'UPDATE',
beforeData: measurement,
afterData: updated,
}),
);
expect(automation.evaluateVarroaMeasurement).toHaveBeenCalledWith(updated);
});
it('findByColony orders results chronologically', async () => { it('findByColony orders results chronologically', async () => {
prisma.varroaMeasurement.findMany.mockResolvedValue([measurement]); prisma.varroaMeasurement.findMany.mockResolvedValue([measurement]);
@@ -4,6 +4,7 @@ import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { VarroaMeasurement } from '../../generated/prisma'; import { VarroaMeasurement } from '../../generated/prisma';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
@Injectable() @Injectable()
export class VarroaService { export class VarroaService {
@@ -53,4 +54,26 @@ export class VarroaService {
await this.automation.evaluateVarroaMeasurement(measurement); await this.automation.evaluateVarroaMeasurement(measurement);
return measurement; return measurement;
} }
async update(
id: string,
dto: UpdateVarroaMeasurementDto,
actingUserId: string,
): Promise<VarroaMeasurement> {
const before = await this.findById(id);
const measurement = await this.prisma.varroaMeasurement.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record({
entityType: 'VarroaMeasurement',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: measurement,
});
await this.automation.evaluateVarroaMeasurement(measurement);
return measurement;
}
} }
@@ -0,0 +1,97 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateBottlingBatch } from "@/lib/bottling/api";
import { BottlingBatch } from "@/lib/bottling/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingBatch }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
const [bestBeforeDate, setBestBeforeDate] = useState(
bottlingBatch.bestBeforeDate.slice(0, 10),
);
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
const mutation = useMutation({
mutationFn: () =>
updateBottlingBatch(bottlingBatch.id, {
date: new Date(date).toISOString(),
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
lotNumber,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["bottling-batches", bottlingBatch.id],
});
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Abfüllung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-date">Datum</Label>
<Input
id="bottling-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-best-before">Mindesthaltbarkeitsdatum</Label>
<Input
id="bottling-edit-best-before"
type="date"
value={bestBeforeDate}
onChange={(event) => setBestBeforeDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
<Input
id="bottling-edit-lot"
required
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { fetchBottlingBatch } from "@/lib/bottling/api"; import { fetchBottlingBatch } from "@/lib/bottling/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { BottlingEditDialog } from "./bottling-edit-dialog";
export default function BottlingBatchDetailPage() { export default function BottlingBatchDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -39,11 +40,14 @@ export default function BottlingBatchDetailPage() {
</span> </span>
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle> <CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
</div> </div>
<QrCodeDialog <div className="flex items-center gap-2">
entityType="bottling-batches" <BottlingEditDialog bottlingBatch={bottlingBatch} />
id={bottlingBatch.id} <QrCodeDialog
label="QR-Code für Etikett" entityType="bottling-batches"
/> id={bottlingBatch.id}
label="QR-Code für Etikett"
/>
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-2 text-sm"> <CardContent className="flex flex-col gap-2 text-sm">
<p>Honigcharge: {bottlingBatch.honeyBatch.batchNumber}</p> <p>Honigcharge: {bottlingBatch.honeyBatch.batchNumber}</p>
@@ -7,6 +7,7 @@ import { fetchColonyFeedings, fetchColonyFeedingsSummary } from "@/lib/feedings/
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FeedingEditDialog } from "./feeding-edit-dialog";
export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) { export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
const { data: feedings, isLoading } = useQuery({ const { data: feedings, isLoading } = useQuery({
@@ -49,9 +50,12 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
) : null} ) : null}
{feedings?.map((feeding) => ( {feedings?.map((feeding) => (
<div key={feeding.id} className="rounded-md border p-3"> <div key={feeding.id} className="rounded-md border p-3">
<p className="font-medium"> <div className="flex items-center justify-between">
{new Date(feeding.date).toLocaleDateString("de-DE")} · {feeding.feedType} <p className="font-medium">
</p> {new Date(feeding.date).toLocaleDateString("de-DE")} · {feeding.feedType}
</p>
<FeedingEditDialog feeding={feeding} />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{feeding.amount} {feeding.unit} {feeding.amount} {feeding.unit}
{feeding.product ? ` · ${feeding.product}` : ""} {feeding.product ? ` · ${feeding.product}` : ""}
@@ -7,6 +7,7 @@ import { fetchColonyInspections } from "@/lib/inspections/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InspectionEditDialog } from "./inspection-edit-dialog";
export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) { export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
const { data: inspections, isLoading } = useQuery({ const { data: inspections, isLoading } = useQuery({
@@ -37,9 +38,12 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
) : null} ) : null}
{inspections?.map((inspection) => ( {inspections?.map((inspection) => (
<div key={inspection.id} className="rounded-md border p-3"> <div key={inspection.id} className="rounded-md border p-3">
<p className="font-medium"> <div className="flex items-center justify-between">
{new Date(inspection.date).toLocaleDateString("de-DE")} <p className="font-medium">
</p> {new Date(inspection.date).toLocaleDateString("de-DE")}
</p>
<InspectionEditDialog inspection={inspection} />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{inspection.colonyStrength !== null {inspection.colonyStrength !== null
? `${inspection.colonyStrength} Waben` ? `${inspection.colonyStrength} Waben`
@@ -7,6 +7,7 @@ import { fetchColonyTreatments } from "@/lib/treatments/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TreatmentEditDialog } from "./treatment-edit-dialog";
export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) { export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
const { data: treatments, isLoading } = useQuery({ const { data: treatments, isLoading } = useQuery({
@@ -37,9 +38,12 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
) : null} ) : null}
{treatments?.map((treatment) => ( {treatments?.map((treatment) => (
<div key={treatment.id} className="rounded-md border p-3"> <div key={treatment.id} className="rounded-md border p-3">
<p className="font-medium"> <div className="flex items-center justify-between">
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product} <p className="font-medium">
</p> {new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
</p>
<TreatmentEditDialog treatment={treatment} />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{treatment.amount ?? "—"} {treatment.amount ?? "—"}
{treatment.method ? ` · ${treatment.method}` : ""} {treatment.method ? ` · ${treatment.method}` : ""}
@@ -7,6 +7,7 @@ import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 280; const CHART_WIDTH = 280;
const CHART_HEIGHT = 80; const CHART_HEIGHT = 80;
@@ -75,9 +76,12 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
<VarroaTrendChart values={values} /> <VarroaTrendChart values={values} />
{measurements?.map((measurement) => ( {measurements?.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3"> <div key={measurement.id} className="rounded-md border p-3">
<p className="font-medium"> <div className="flex items-center justify-between">
{new Date(measurement.date).toLocaleDateString("de-DE")} · {measurement.method} <p className="font-medium">
</p> {new Date(measurement.date).toLocaleDateString("de-DE")} · {measurement.method}
</p>
<VarroaEditDialog measurement={measurement} />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"} {measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
{measurement.calculatedValue !== null {measurement.calculatedValue !== null
@@ -0,0 +1,152 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateFeeding } from "@/lib/feedings/api";
import { Feeding, FeedType } from "@/lib/feedings/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const FEED_TYPE_OPTIONS: FeedType[] = [
"SUGAR_WATER",
"FEED_SYRUP",
"FEED_PASTE",
"OWN_HONEY",
"OTHER",
];
export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(feeding.date.slice(0, 10));
const [feedType, setFeedType] = useState<FeedType>(feeding.feedType);
const [product, setProduct] = useState(feeding.product ?? "");
const [amount, setAmount] = useState(feeding.amount.toString());
const [unit, setUnit] = useState(feeding.unit);
const [notes, setNotes] = useState(feeding.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateFeeding(feeding.id, {
date: new Date(date).toISOString(),
feedType,
product: product || undefined,
amount: amount ? Number(amount) : undefined,
unit,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", feeding.colonyId, "feedings"],
});
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Fütterung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-date">Datum</Label>
<Input
id="feeding-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-type">Futterart</Label>
<Select
value={feedType}
onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)}
>
<SelectTrigger id="feeding-edit-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FEED_TYPE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="feeding-edit-amount">Menge</Label>
<Input
id="feeding-edit-amount"
type="number"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="feeding-edit-unit">Einheit</Label>
<Input
id="feeding-edit-unit"
value={unit}
onChange={(event) => setUnit(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-product">Produkt</Label>
<Input
id="feeding-edit-product"
value={product}
onChange={(event) => setProduct(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="feeding-edit-notes">Notizen</Label>
<Textarea
id="feeding-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,176 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateInspection } from "@/lib/inspections/api";
import { Inspection } from "@/lib/inspections/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(inspection.date.slice(0, 10));
const [colonyStrength, setColonyStrength] = useState(
inspection.colonyStrength?.toString() ?? "",
);
const [queenSeen, setQueenSeen] = useState(inspection.queenSeen ?? false);
const [eggsPresent, setEggsPresent] = useState(inspection.eggsPresent ?? true);
const [feedStatus, setFeedStatus] = useState(inspection.feedStatus ?? "ausreichend");
const [healthIssues, setHealthIssues] = useState(inspection.healthIssues ?? "");
const [varroaIssues, setVarroaIssues] = useState(inspection.varroaIssues ?? "");
const [measures, setMeasures] = useState(inspection.measures ?? "");
const [notes, setNotes] = useState(inspection.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateInspection(inspection.id, {
date: new Date(date).toISOString(),
colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
queenSeen,
eggsPresent,
feedStatus,
healthIssues: healthIssues || undefined,
varroaIssues: varroaIssues || undefined,
measures: measures || undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", inspection.colonyId, "inspections"],
});
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Durchsicht bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-date">Datum</Label>
<Input
id="inspection-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-strength">Volksstärke (Waben)</Label>
<Input
id="inspection-edit-strength"
type="number"
value={colonyStrength}
onChange={(event) => setColonyStrength(event.target.value)}
/>
</div>
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-queen">
<input
id="inspection-edit-queen"
type="checkbox"
className="size-5"
checked={queenSeen}
onChange={(event) => setQueenSeen(event.target.checked)}
/>
Königin gesehen
</label>
<label className="flex items-center gap-3 text-sm" htmlFor="inspection-edit-eggs">
<input
id="inspection-edit-eggs"
type="checkbox"
className="size-5"
checked={eggsPresent}
onChange={(event) => setEggsPresent(event.target.checked)}
/>
Stifte vorhanden
</label>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-feed">Futterstatus</Label>
<Select
value={feedStatus}
onValueChange={(value) => setFeedStatus((value ?? "ausreichend") as string)}
>
<SelectTrigger id="inspection-edit-feed" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ausreichend">Ausreichend</SelectItem>
<SelectItem value="niedrig">Niedrig</SelectItem>
<SelectItem value="kritisch">Kritisch</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-varroa">Varroa-Befund</Label>
<Input
id="inspection-edit-varroa"
value={varroaIssues}
onChange={(event) => setVarroaIssues(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-health">Gesundheitsauffälligkeiten</Label>
<Input
id="inspection-edit-health"
value={healthIssues}
onChange={(event) => setHealthIssues(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-measures">Maßnahmen</Label>
<Textarea
id="inspection-edit-measures"
value={measures}
onChange={(event) => setMeasures(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="inspection-edit-notes">Notizen</Label>
<Textarea
id="inspection-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,125 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTreatment } from "@/lib/treatments/api";
import { Treatment } from "@/lib/treatments/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(treatment.date.slice(0, 10));
const [product, setProduct] = useState(treatment.product);
const [amount, setAmount] = useState(treatment.amount ?? "");
const [method, setMethod] = useState(treatment.method ?? "");
const [reason, setReason] = useState(treatment.reason ?? "");
const [notes, setNotes] = useState(treatment.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateTreatment(treatment.id, {
date: new Date(date).toISOString(),
product,
amount: amount || undefined,
method: method || undefined,
reason: reason || undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", treatment.colonyId, "treatments"],
});
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Behandlung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-date">Datum</Label>
<Input
id="treatment-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-product">Mittel</Label>
<Input
id="treatment-edit-product"
required
value={product}
onChange={(event) => setProduct(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-amount">Menge</Label>
<Input
id="treatment-edit-amount"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-method">Methode</Label>
<Input
id="treatment-edit-method"
value={method}
onChange={(event) => setMethod(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-reason">Grund</Label>
<Input
id="treatment-edit-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-notes">Notizen</Label>
<Textarea
id="treatment-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,153 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const METHOD_OPTIONS: VarroaMethod[] = [
"NATURAL_MITE_FALL",
"POWDERED_SUGAR",
"WASHING_METHOD",
"DRONE_BROOD_CONTROL",
];
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(measurement.date.slice(0, 10));
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
const [calculatedValue, setCalculatedValue] = useState(
measurement.calculatedValue?.toString() ?? "",
);
const [assessment, setAssessment] = useState(measurement.assessment ?? "");
const [notes, setNotes] = useState(measurement.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateVarroaMeasurement(measurement.id, {
date: new Date(date).toISOString(),
method,
miteCount: miteCount ? Number(miteCount) : undefined,
calculatedValue: calculatedValue ? Number(calculatedValue) : undefined,
assessment: assessment || undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
});
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Varroamessung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-date">Datum</Label>
<Input
id="varroa-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-method">Methode</Label>
<Select
value={method}
onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)}
>
<SelectTrigger id="varroa-edit-method" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{METHOD_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-count">Milbenzahl</Label>
<Input
id="varroa-edit-count"
type="number"
value={miteCount}
onChange={(event) => setMiteCount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-calculated">Milben pro Tag</Label>
<Input
id="varroa-edit-calculated"
type="number"
step="0.1"
value={calculatedValue}
onChange={(event) => setCalculatedValue(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-assessment">Einschätzung</Label>
<Input
id="varroa-edit-assessment"
value={assessment}
onChange={(event) => setAssessment(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="varroa-edit-notes">Notizen</Label>
<Textarea
id="varroa-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,118 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateHarvest } from "@/lib/harvests/api";
import { Harvest } from "@/lib/harvests/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(harvest.date.slice(0, 10));
const [honeyType, setHoneyType] = useState(harvest.honeyType ?? "");
const [netWeight, setNetWeight] = useState(harvest.netWeight?.toString() ?? "");
const [honeySuperCount, setHoneySuperCount] = useState(
harvest.honeySuperCount?.toString() ?? "",
);
const [notes, setNotes] = useState(harvest.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateHarvest(harvest.id, {
date: new Date(date).toISOString(),
honeyType: honeyType || undefined,
netWeight: netWeight ? Number(netWeight) : undefined,
honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["harvests", harvest.id] });
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Ernte bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-date">Datum</Label>
<Input
id="harvest-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-type">Honigsorte</Label>
<Input
id="harvest-edit-type"
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="harvest-edit-weight">Nettogewicht (kg)</Label>
<Input
id="harvest-edit-weight"
type="number"
value={netWeight}
onChange={(event) => setNetWeight(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="harvest-edit-supers">Honigräume</Label>
<Input
id="harvest-edit-supers"
type="number"
value={honeySuperCount}
onChange={(event) => setHoneySuperCount(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-notes">Notizen</Label>
<Textarea
id="harvest-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { fetchHarvest } from "@/lib/harvests/api"; import { fetchHarvest } from "@/lib/harvests/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { HarvestEditDialog } from "./harvest-edit-dialog";
export default function HarvestDetailPage() { export default function HarvestDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -29,14 +30,17 @@ export default function HarvestDetailPage() {
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">
<Card className="max-w-lg"> <Card className="max-w-lg">
<CardHeader className="flex flex-row items-center gap-2.5"> <CardHeader className="flex flex-row items-center justify-between gap-2.5">
<span <div className="flex items-center gap-2.5">
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]" <span
style={{ background: "var(--gradient-honey)" }} className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
> style={{ background: "var(--gradient-honey)" }}
<Boxes className="size-4" /> >
</span> <Boxes className="size-4" />
<CardTitle>Ernte vom {new Date(harvest.date).toLocaleDateString("de-DE")}</CardTitle> </span>
<CardTitle>Ernte vom {new Date(harvest.date).toLocaleDateString("de-DE")}</CardTitle>
</div>
<HarvestEditDialog harvest={harvest} />
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm">
{harvest.honeyType ? <p>Honigsorte: {harvest.honeyType}</p> : null} {harvest.honeyType ? <p>Honigsorte: {harvest.honeyType}</p> : null}
@@ -0,0 +1,158 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchExtraction, updateExtraction } from "@/lib/extractions/api";
import { Extraction } from "@/lib/extractions/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
function ExtractionEditForm({
extraction,
honeyBatchId,
onSaved,
}: {
extraction: Extraction;
honeyBatchId: string;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [date, setDate] = useState(extraction.date.slice(0, 10));
const [room, setRoom] = useState(extraction.room ?? "");
const [waterContent, setWaterContent] = useState(
extraction.waterContent?.toString() ?? "",
);
const [temperature, setTemperature] = useState(
extraction.temperature?.toString() ?? "",
);
const [notes, setNotes] = useState(extraction.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateExtraction(extraction.id, {
date: new Date(date).toISOString(),
room: room || undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["honey-batches", honeyBatchId, "traceability"],
});
onSaved();
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-date">Datum</Label>
<Input
id="extraction-edit-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-room">Raum</Label>
<Input
id="extraction-edit-room"
value={room}
onChange={(event) => setRoom(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="extraction-edit-water">Wassergehalt (%)</Label>
<Input
id="extraction-edit-water"
type="number"
step="0.1"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="extraction-edit-temp">Temperatur (°C)</Label>
<Input
id="extraction-edit-temp"
type="number"
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="extraction-edit-notes">Notizen</Label>
<Textarea
id="extraction-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
);
}
export function ExtractionEditDialog({
extractionId,
honeyBatchId,
}: {
extractionId: string;
honeyBatchId: string;
}) {
const [open, setOpen] = useState(false);
const { data: extraction } = useQuery({
queryKey: ["extractions", extractionId],
queryFn: () => fetchExtraction(extractionId),
enabled: open,
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
</DialogHeader>
{!extraction ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{extraction ? (
<ExtractionEditForm
key={extraction.id}
extraction={extraction}
honeyBatchId={honeyBatchId}
onSaved={() => setOpen(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AddMeasurementDialog } from "./add-measurement-dialog"; import { AddMeasurementDialog } from "./add-measurement-dialog";
import { ExtractionEditDialog } from "./extraction-edit-dialog";
export default function HoneyBatchDetailPage() { export default function HoneyBatchDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -115,21 +116,31 @@ export default function HoneyBatchDetailPage() {
{!traceability ? <p className="text-muted-foreground">Lädt…</p> : null} {!traceability ? <p className="text-muted-foreground">Lädt…</p> : null}
{traceability ? ( {traceability ? (
<> <>
<p> <div className="flex items-center justify-between">
Schleuderung am{" "} <p>
{new Date(traceability.extraction.date).toLocaleDateString("de-DE")} Schleuderung am{" "}
{traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""} {new Date(traceability.extraction.date).toLocaleDateString("de-DE")}
</p> {traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""}
</p>
<ExtractionEditDialog
extractionId={traceability.extraction.id}
honeyBatchId={honeyBatch.id}
/>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{traceability.harvests.map((harvest) => ( {traceability.harvests.map((harvest) => (
<div key={harvest.id} className="rounded-md border p-3"> <Link
key={harvest.id}
href={`/harvests/${harvest.id}`}
className="rounded-md border p-3 hover:bg-muted"
>
<p className="font-medium"> <p className="font-medium">
Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")} Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")}
</p> </p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Völker: {harvest.colonies.map((colony) => colony.code).join(", ")} Völker: {harvest.colonies.map((colony) => colony.code).join(", ")}
</p> </p>
</div> </Link>
))} ))}
</div> </div>
</> </>
+14 -10
View File
@@ -25,6 +25,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { TaskEditDialog } from "./task-edit-dialog";
const STATUS_OPTIONS: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"]; const STATUS_OPTIONS: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"]; const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
@@ -160,16 +161,19 @@ export default function TasksPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
{task.status !== "DONE" && task.status !== "CANCELLED" ? ( <div className="flex items-center gap-2">
<Button <TaskEditDialog task={task} />
size="sm" {task.status !== "DONE" && task.status !== "CANCELLED" ? (
variant="outline" <Button
disabled={completeMutation.isPending} size="sm"
onClick={() => completeMutation.mutate(task.id)} variant="outline"
> disabled={completeMutation.isPending}
Erledigen onClick={() => completeMutation.mutate(task.id)}
</Button> >
) : null} Erledigen
</Button>
) : null}
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -0,0 +1,122 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTask } from "@/lib/tasks/api";
import { Task, TaskPriority } from "@/lib/tasks/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
export function TaskEditDialog({ task }: { task: Task }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description ?? "");
const [priority, setPriority] = useState<TaskPriority>(task.priority);
const [dueDate, setDueDate] = useState(task.dueDate?.slice(0, 10) ?? "");
const mutation = useMutation({
mutationFn: () =>
updateTask(task.id, {
title,
description: description || undefined,
priority,
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Aufgabe bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="task-edit-title">Titel</Label>
<Input
id="task-edit-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-edit-priority">Priorität</Label>
<Select
value={priority}
onValueChange={(value) => setPriority((value ?? priority) as TaskPriority)}
>
<SelectTrigger id="task-edit-priority" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-edit-due">Fällig am</Label>
<Input
id="task-edit-due"
type="date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="task-edit-description">Beschreibung</Label>
<Textarea
id="task-edit-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
+16 -1
View File
@@ -1,5 +1,10 @@
import { apiFetch } from "@/lib/auth/api-client"; import { apiFetch } from "@/lib/auth/api-client";
import { BottlingBatch, BottlingBatchFilter, CreateBottlingBatchInput } from "./types"; import {
BottlingBatch,
BottlingBatchFilter,
CreateBottlingBatchInput,
UpdateBottlingBatchInput,
} from "./types";
export function fetchBottlingBatches( export function fetchBottlingBatches(
filter: BottlingBatchFilter = {}, filter: BottlingBatchFilter = {},
@@ -22,3 +27,13 @@ export function createBottlingBatch(
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function updateBottlingBatch(
id: string,
input: UpdateBottlingBatchInput,
): Promise<BottlingBatch> {
return apiFetch<BottlingBatch>(`/bottling-batches/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -23,6 +23,13 @@ export interface CreateBottlingBatchInput {
lotNumber: string; lotNumber: string;
} }
export interface UpdateBottlingBatchInput {
productId?: string;
date?: string;
bestBeforeDate?: string;
lotNumber?: string;
}
export interface BottlingBatchFilter { export interface BottlingBatchFilter {
honeyBatchId?: string; honeyBatchId?: string;
} }
+15 -1
View File
@@ -1,13 +1,27 @@
import { apiFetch } from "@/lib/auth/api-client"; import { apiFetch } from "@/lib/auth/api-client";
import { CreateExtractionInput, Extraction } from "./types"; import { CreateExtractionInput, Extraction, UpdateExtractionInput } from "./types";
export function fetchExtractions(): Promise<Extraction[]> { export function fetchExtractions(): Promise<Extraction[]> {
return apiFetch<Extraction[]>("/extractions"); return apiFetch<Extraction[]>("/extractions");
} }
export function fetchExtraction(id: string): Promise<Extraction> {
return apiFetch<Extraction>(`/extractions/${id}`);
}
export function createExtraction(input: CreateExtractionInput): Promise<Extraction> { export function createExtraction(input: CreateExtractionInput): Promise<Extraction> {
return apiFetch<Extraction>("/extractions", { return apiFetch<Extraction>("/extractions", {
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function updateExtraction(
id: string,
input: UpdateExtractionInput,
): Promise<Extraction> {
return apiFetch<Extraction>(`/extractions/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -33,3 +33,12 @@ export interface CreateExtractionInput {
temperature?: number; temperature?: number;
notes?: string; notes?: string;
} }
export interface UpdateExtractionInput {
date?: string;
amount?: number;
room?: string;
waterContent?: number;
temperature?: number;
notes?: string;
}
+16 -1
View File
@@ -1,5 +1,10 @@
import { apiFetch } from "@/lib/auth/api-client"; import { apiFetch } from "@/lib/auth/api-client";
import { CreateFeedingInput, Feeding, FeedingSummaryEntry } from "./types"; import {
CreateFeedingInput,
Feeding,
FeedingSummaryEntry,
UpdateFeedingInput,
} from "./types";
export function fetchColonyFeedings(colonyId: string): Promise<Feeding[]> { export function fetchColonyFeedings(colonyId: string): Promise<Feeding[]> {
return apiFetch<Feeding[]>(`/colonies/${colonyId}/feedings`); return apiFetch<Feeding[]>(`/colonies/${colonyId}/feedings`);
@@ -15,3 +20,13 @@ export function createFeeding(input: CreateFeedingInput): Promise<Feeding> {
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function updateFeeding(
id: string,
input: UpdateFeedingInput,
): Promise<Feeding> {
return apiFetch<Feeding>(`/feedings/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -29,3 +29,13 @@ export interface CreateFeedingInput {
batchNumber?: string; batchNumber?: string;
notes?: string; notes?: string;
} }
export interface UpdateFeedingInput {
date?: string;
feedType?: FeedType;
product?: string;
amount?: number;
unit?: string;
batchNumber?: string;
notes?: string;
}
+8 -1
View File
@@ -1,5 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client"; import { apiFetch } from "@/lib/auth/api-client";
import { CreateHarvestInput, Harvest } from "./types"; import { CreateHarvestInput, Harvest, UpdateHarvestInput } from "./types";
export function fetchHarvests(): Promise<Harvest[]> { export function fetchHarvests(): Promise<Harvest[]> {
return apiFetch<Harvest[]>("/harvests"); return apiFetch<Harvest[]>("/harvests");
@@ -15,3 +15,10 @@ export function createHarvest(input: CreateHarvestInput): Promise<Harvest> {
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function updateHarvest(id: string, input: UpdateHarvestInput): Promise<Harvest> {
return apiFetch<Harvest>(`/harvests/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -31,3 +31,12 @@ export interface CreateHarvestInput {
netWeight?: number; netWeight?: number;
notes?: string; notes?: string;
} }
export interface UpdateHarvestInput {
date?: string;
honeyType?: string;
honeySuperCount?: number;
grossWeight?: number;
netWeight?: number;
notes?: string;
}
@@ -4,6 +4,7 @@ import {
CreateTaskFromInspectionInput, CreateTaskFromInspectionInput,
Inspection, Inspection,
InspectionTask, InspectionTask,
UpdateInspectionInput,
} from "./types"; } from "./types";
export function fetchColonyInspections(colonyId: string): Promise<Inspection[]> { export function fetchColonyInspections(colonyId: string): Promise<Inspection[]> {
@@ -17,6 +18,16 @@ export function createInspection(input: CreateInspectionInput): Promise<Inspecti
}); });
} }
export function updateInspection(
id: string,
input: UpdateInspectionInput,
): Promise<Inspection> {
return apiFetch<Inspection>(`/inspections/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function createTaskFromInspection( export function createTaskFromInspection(
id: string, id: string,
input: CreateTaskFromInspectionInput, input: CreateTaskFromInspectionInput,
@@ -41,6 +41,21 @@ export interface CreateInspectionInput {
notes?: string; notes?: string;
} }
export interface UpdateInspectionInput {
date?: string;
weather?: string;
temperature?: number;
colonyStrength?: number;
queenSeen?: boolean;
eggsPresent?: boolean;
queenCellsPresent?: boolean;
feedStatus?: string;
healthIssues?: string;
varroaIssues?: string;
measures?: string;
notes?: string;
}
export interface CreateTaskFromInspectionInput { export interface CreateTaskFromInspectionInput {
title: string; title: string;
description?: string; description?: string;
+11 -1
View File
@@ -1,5 +1,5 @@
import { apiFetch } from "@/lib/auth/api-client"; import { apiFetch } from "@/lib/auth/api-client";
import { CreateTreatmentInput, Treatment } from "./types"; import { CreateTreatmentInput, Treatment, UpdateTreatmentInput } from "./types";
export function fetchColonyTreatments(colonyId: string): Promise<Treatment[]> { export function fetchColonyTreatments(colonyId: string): Promise<Treatment[]> {
return apiFetch<Treatment[]>(`/colonies/${colonyId}/treatments`); return apiFetch<Treatment[]>(`/colonies/${colonyId}/treatments`);
@@ -11,3 +11,13 @@ export function createTreatment(input: CreateTreatmentInput): Promise<Treatment>
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function updateTreatment(
id: string,
input: UpdateTreatmentInput,
): Promise<Treatment> {
return apiFetch<Treatment>(`/treatments/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -28,3 +28,16 @@ export interface CreateTreatmentInput {
batchNumber?: string; batchNumber?: string;
notes?: string; notes?: string;
} }
export interface UpdateTreatmentInput {
date?: string;
startDate?: string;
endDate?: string;
product?: string;
activeIngredient?: string;
amount?: string;
method?: string;
reason?: string;
batchNumber?: string;
notes?: string;
}
+15 -1
View File
@@ -1,5 +1,9 @@
import { apiFetch } from "@/lib/auth/api-client"; import { apiFetch } from "@/lib/auth/api-client";
import { CreateVarroaMeasurementInput, VarroaMeasurement } from "./types"; import {
CreateVarroaMeasurementInput,
UpdateVarroaMeasurementInput,
VarroaMeasurement,
} from "./types";
export function fetchColonyVarroaMeasurements(colonyId: string): Promise<VarroaMeasurement[]> { export function fetchColonyVarroaMeasurements(colonyId: string): Promise<VarroaMeasurement[]> {
return apiFetch<VarroaMeasurement[]>(`/colonies/${colonyId}/varroa-measurements`); return apiFetch<VarroaMeasurement[]>(`/colonies/${colonyId}/varroa-measurements`);
@@ -13,3 +17,13 @@ export function createVarroaMeasurement(
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
} }
export function updateVarroaMeasurement(
id: string,
input: UpdateVarroaMeasurementInput,
): Promise<VarroaMeasurement> {
return apiFetch<VarroaMeasurement>(`/varroa-measurements/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
+10
View File
@@ -28,3 +28,13 @@ export interface CreateVarroaMeasurementInput {
assessment?: string; assessment?: string;
notes?: string; notes?: string;
} }
export interface UpdateVarroaMeasurementInput {
date?: string;
method?: VarroaMethod;
durationMinutes?: number;
miteCount?: number;
calculatedValue?: number;
assessment?: string;
notes?: string;
}