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:
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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user