feat: make the parent relation of every record correctable after entry
An inspection, treatment, feeding, varroa measurement or swarm control check filed against the wrong colony could not be repaired: the colony was frozen at creation time, and with no delete endpoint either, the only remedy was leaving a wrong record in the colony's history. The same held for a harvest booked against the wrong apiary or with a colony forgotten, for an extraction linked to the wrong harvests, and for a honey batch whose number or source extraction was mistyped. Backend: - The five colony-scoped update DTOs now cover the full create DTO, colonyId included. - Harvest update accepts apiaryId and colonyIds, extraction update accepts harvestIds. Both validate the ids first and replace the whole join-row set inside the existing transaction; the join rows carry no data of their own, so replacing beats diffing. - Honey batch update accepts batchNumber, extractionId and status. Status was previously only ever set to BOTTLED by the bottling module, so SOLD and CONSUMED were unreachable through the API. - Colony and storage container codes, and a task's relatedType/relatedId, are editable. - apiaryId on a colony and storageContainerId on a honey batch stay out of PATCH on purpose: both have dedicated endpoints that also write the history entry (ColonyApiaryHistory, HoneyBatchRelocation) the traceability chain depends on. - AllExceptionsFilter now also translates Prisma P2003 to a 400 and P2025 to a 404. Services write straight against the foreign keys with no application level existence check, so before this a stale or mistyped relation id came back as a bare 500 and filled the log with fake server errors. Frontend: - New shared fields: ColonySelectField (used by all five colony-scoped edit dialogs), HarvestSelectField (both extraction dialogs) and TaskRelatedField. - Re-filing a record invalidates the lists of both the colony it left and the one it joined, otherwise one of the two stays stale. - The harvest dialog gained an apiary select and a colony checklist that keeps already-linked colonies visible even when they moved or were dissolved; the honey batch dialog gained batch number, source extraction and status. - Extraction and harvest forms refuse to submit an empty relation set instead of letting the backend reject it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2b15795a60
commit
bee2199647
44 files changed
+958
-156
No files matched your search
@@ -3,8 +3,15 @@ import { IsEnum, IsOptional } from 'class-validator';
|
|||||||
import { ColonyStatus } from '../../../generated/prisma';
|
import { ColonyStatus } from '../../../generated/prisma';
|
||||||
import { CreateColonyDto } from './create-colony.dto';
|
import { CreateColonyDto } from './create-colony.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `apiaryId` is deliberately absent: moving a colony to another apiary runs
|
||||||
|
* through POST /colonies/:id/move, which also writes the ColonyApiaryHistory
|
||||||
|
* entry the traceability chain depends on. A plain PATCH would move the
|
||||||
|
* colony without that record.
|
||||||
|
*/
|
||||||
class UpdatableColonyFieldsDto extends PartialType(
|
class UpdatableColonyFieldsDto extends PartialType(
|
||||||
PickType(CreateColonyDto, [
|
PickType(CreateColonyDto, [
|
||||||
|
'code',
|
||||||
'hiveType',
|
'hiveType',
|
||||||
'originText',
|
'originText',
|
||||||
'colonyType',
|
'colonyType',
|
||||||
|
|||||||
@@ -123,4 +123,57 @@ describe('AllExceptionsFilter', () => {
|
|||||||
expect(warnSpy).toHaveBeenCalled();
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
expect(errorSpy).not.toHaveBeenCalled();
|
expect(errorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates a P2003 foreign-key error to a 400 naming the broken relation', () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'Foreign key constraint failed on the field: `colonyId`',
|
||||||
|
{
|
||||||
|
code: 'P2003',
|
||||||
|
clientVersion: '6.0.0',
|
||||||
|
meta: { field_name: 'colonyId' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, host);
|
||||||
|
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(400);
|
||||||
|
expect(jsonMock).toHaveBeenCalledWith({
|
||||||
|
statusCode: 400,
|
||||||
|
message:
|
||||||
|
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates a P2025 missing-record error to a 404', () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'An operation failed because it depends on one or more records that were required but not found.',
|
||||||
|
{ code: 'P2025', clientVersion: '6.0.0' },
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, host);
|
||||||
|
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(404);
|
||||||
|
expect(jsonMock).toHaveBeenCalledWith({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Datensatz nicht gefunden',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unmapped Prisma error as a generic 500, leaking nothing', () => {
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'Timed out fetching a new connection from the connection pool',
|
||||||
|
{ code: 'P2024', clientVersion: '6.0.0' },
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, host);
|
||||||
|
|
||||||
|
expect(statusMock).toHaveBeenCalledWith(500);
|
||||||
|
expect(jsonMock).toHaveBeenCalledWith({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Internal server error',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -34,6 +34,45 @@ function duplicateFieldMessage(target: unknown): string {
|
|||||||
: 'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen';
|
: 'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A relation pointed at a record that does not exist (Prisma P2003). Every
|
||||||
|
* module lets a record be re-pointed at a different parent - an inspection
|
||||||
|
* filed against the wrong colony, a harvest against the wrong apiary - and
|
||||||
|
* relies on the foreign key rather than an application-level existence check,
|
||||||
|
* so a stale or mistyped id has to come back as a clean 400.
|
||||||
|
*/
|
||||||
|
const FOREIGN_KEY_MESSAGE =
|
||||||
|
'Der verknüpfte Datensatz existiert nicht (mehr) - bitte die Auswahl prüfen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates the Prisma errors that are really user input problems into the
|
||||||
|
* status and message they deserve. Anything not listed here stays a 500 with
|
||||||
|
* a generic body, so no internal detail leaks.
|
||||||
|
*/
|
||||||
|
function prismaErrorResponse(
|
||||||
|
exception: unknown,
|
||||||
|
): { status: number; message: string } | null {
|
||||||
|
if (!(exception instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (exception.code) {
|
||||||
|
case 'P2002':
|
||||||
|
return {
|
||||||
|
status: HttpStatus.CONFLICT,
|
||||||
|
message: duplicateFieldMessage(exception.meta?.target),
|
||||||
|
};
|
||||||
|
case 'P2003':
|
||||||
|
return { status: HttpStatus.BAD_REQUEST, message: FOREIGN_KEY_MESSAGE };
|
||||||
|
case 'P2025':
|
||||||
|
return {
|
||||||
|
status: HttpStatus.NOT_FOUND,
|
||||||
|
message: 'Datensatz nicht gefunden',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces Nest's default exception handling so EVERY error - not just
|
* Replaces Nest's default exception handling so EVERY error - not just
|
||||||
* unhandled 5xx ones - shows up in stdout/kubectl logs. Nest's built-in
|
* unhandled 5xx ones - shows up in stdout/kubectl logs. Nest's built-in
|
||||||
@@ -46,12 +85,13 @@ function duplicateFieldMessage(target: unknown): string {
|
|||||||
* Client-facing behaviour is unchanged: HttpExceptions still return
|
* Client-facing behaviour is unchanged: HttpExceptions still return
|
||||||
* exactly the body/status Nest would have produced. Non-HttpException
|
* exactly the body/status Nest would have produced. Non-HttpException
|
||||||
* errors still collapse to a generic 500 body - the real message and
|
* errors still collapse to a generic 500 body - the real message and
|
||||||
* stack go to the log, never to the client. The one exception is Prisma's
|
* stack go to the log, never to the client. The exceptions are the Prisma
|
||||||
* P2002 (unique constraint violation): services create/update records
|
* errors listed in prismaErrorResponse: services write records straight
|
||||||
* straight off a DB `@unique` column (colony/apiary/queen codes, SKUs,
|
* against DB constraints (`@unique` columns like colony/apiary/queen codes,
|
||||||
* batch numbers) with no application-level duplicate check, relying on
|
* SKUs and batch numbers; foreign keys for every parent relation) with no
|
||||||
* the constraint itself - so without this branch, a beekeeper reusing a
|
* application-level check, relying on the constraint itself - so without
|
||||||
* code by mistake saw a bare 500 "Internal server error" instead of a
|
* those branches, a beekeeper reusing a code or picking a record that was
|
||||||
|
* meanwhile removed saw a bare 500 "Internal server error" instead of a
|
||||||
* clean, specific message, and the log filled with fake server errors for
|
* clean, specific message, and the log filled with fake server errors for
|
||||||
* what is really a user input problem.
|
* what is really a user input problem.
|
||||||
*/
|
*/
|
||||||
@@ -65,22 +105,16 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
const isHttpException = exception instanceof HttpException;
|
const isHttpException = exception instanceof HttpException;
|
||||||
const isUniqueConstraintError =
|
const prismaResponse = prismaErrorResponse(exception);
|
||||||
exception instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
exception.code === 'P2002';
|
|
||||||
const status = isHttpException
|
const status = isHttpException
|
||||||
? exception.getStatus()
|
? exception.getStatus()
|
||||||
: isUniqueConstraintError
|
: (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
? HttpStatus.CONFLICT
|
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
|
||||||
const body = isHttpException
|
const body = isHttpException
|
||||||
? exception.getResponse()
|
? exception.getResponse()
|
||||||
: isUniqueConstraintError
|
: {
|
||||||
? {
|
statusCode: status,
|
||||||
statusCode: status,
|
message: prismaResponse?.message ?? 'Internal server error',
|
||||||
message: duplicateFieldMessage(exception.meta?.target),
|
};
|
||||||
}
|
|
||||||
: { statusCode: status, message: 'Internal server error' };
|
|
||||||
const message =
|
const message =
|
||||||
exception instanceof Error ? exception.message : 'Unknown error';
|
exception instanceof Error ? exception.message : 'Unknown error';
|
||||||
const context = `${request.method} ${request.originalUrl} -> ${status}`;
|
const context = `${request.method} ${request.originalUrl} -> ${status}`;
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateExtractionDto } from './create-extraction.dto';
|
import { CreateExtractionDto } from './create-extraction.dto';
|
||||||
|
|
||||||
export class UpdateExtractionDto extends PartialType(
|
/**
|
||||||
PickType(CreateExtractionDto, [
|
* Every field stays correctable, the set of harvests included: a Schleuderung
|
||||||
'date',
|
* linked to the wrong Ernte is a data-entry error, and re-pointing it is the
|
||||||
'amount',
|
* only way to repair the traceability chain without deleting the record.
|
||||||
'room',
|
*/
|
||||||
'waterContent',
|
export class UpdateExtractionDto extends PartialType(CreateExtractionDto) {}
|
||||||
'temperature',
|
|
||||||
'notes',
|
|
||||||
] as const),
|
|
||||||
) {}
|
|
||||||
@@ -12,16 +12,19 @@ describe('ExtractionsService', () => {
|
|||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let tx: {
|
let tx: {
|
||||||
extraction: { create: jest.Mock };
|
extraction: { create: jest.Mock; update: jest.Mock };
|
||||||
extractionHarvest: { createMany: jest.Mock };
|
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
|
||||||
};
|
};
|
||||||
|
|
||||||
const extraction = { id: 'extraction-1', date: new Date() };
|
const extraction = { id: 'extraction-1', date: new Date() };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tx = {
|
tx = {
|
||||||
extraction: { create: jest.fn().mockResolvedValue(extraction) },
|
extraction: {
|
||||||
extractionHarvest: { createMany: jest.fn() },
|
create: jest.fn().mockResolvedValue(extraction),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
|
||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
extraction: { findUnique: jest.fn(), update: jest.fn() },
|
extraction: { findUnique: jest.fn(), update: jest.fn() },
|
||||||
@@ -82,7 +85,6 @@ describe('ExtractionsService', () => {
|
|||||||
prisma.extraction.findUnique
|
prisma.extraction.findUnique
|
||||||
.mockResolvedValueOnce(extraction)
|
.mockResolvedValueOnce(extraction)
|
||||||
.mockResolvedValueOnce(updated);
|
.mockResolvedValueOnce(updated);
|
||||||
prisma.extraction.update.mockResolvedValue(updated);
|
|
||||||
|
|
||||||
const result = await service.update(
|
const result = await service.update(
|
||||||
'extraction-1',
|
'extraction-1',
|
||||||
@@ -100,5 +102,41 @@ describe('ExtractionsService', () => {
|
|||||||
afterData: updated,
|
afterData: updated,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update replaces the whole harvest set when harvestIds is given', async () => {
|
||||||
|
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
'extraction-1',
|
||||||
|
{ harvestIds: ['harvest-1', 'harvest-2'] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.extractionHarvest.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { extractionId: 'extraction-1' },
|
||||||
|
});
|
||||||
|
const [[createArg]] = tx.extractionHarvest.createMany.mock.calls as [
|
||||||
|
[{ data: { extractionId: string; harvestId: string }[] }],
|
||||||
|
];
|
||||||
|
expect(createArg.data).toEqual([
|
||||||
|
{ extractionId: 'extraction-1', harvestId: 'harvest-1' },
|
||||||
|
{ extractionId: 'extraction-1', harvestId: 'harvest-2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update rejects an unknown harvest id before touching the database', async () => {
|
||||||
|
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||||
|
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update(
|
||||||
|
'extraction-1',
|
||||||
|
{ harvestIds: ['harvest-1', 'missing-harvest'] },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,10 +89,32 @@ export class ExtractionsService {
|
|||||||
|
|
||||||
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
|
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
await this.prisma.extraction.update({
|
const { harvestIds, ...scalarFields } = dto;
|
||||||
where: { id },
|
if (harvestIds) {
|
||||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
await this.assertHarvestsExist(harvestIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.extraction.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...scalarFields,
|
||||||
|
date: dto.date ? new Date(dto.date) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The join rows carry no data of their own, so replacing the whole set
|
||||||
|
// is both simpler and safer than diffing it.
|
||||||
|
if (harvestIds) {
|
||||||
|
await tx.extractionHarvest.deleteMany({ where: { extractionId: id } });
|
||||||
|
await tx.extractionHarvest.createMany({
|
||||||
|
data: harvestIds.map((harvestId) => ({
|
||||||
|
extractionId: id,
|
||||||
|
harvestId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const extraction = await this.findById(id);
|
const extraction = await this.findById(id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'Extraction',
|
entityType: 'Extraction',
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateFeedingDto } from './create-feeding.dto';
|
import { CreateFeedingDto } from './create-feeding.dto';
|
||||||
|
|
||||||
export class UpdateFeedingDto extends PartialType(
|
/**
|
||||||
PickType(CreateFeedingDto, [
|
* Every field stays correctable, `colonyId` included: a record filed against
|
||||||
'date',
|
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||||
'feedType',
|
* repair the colony's history without deleting the record.
|
||||||
'product',
|
*/
|
||||||
'amount',
|
export class UpdateFeedingDto extends PartialType(CreateFeedingDto) {}
|
||||||
'unit',
|
|
||||||
'batchNumber',
|
|
||||||
'notes',
|
|
||||||
] as const),
|
|
||||||
) {}
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateHarvestDto } from './create-harvest.dto';
|
import { CreateHarvestDto } from './create-harvest.dto';
|
||||||
|
|
||||||
export class UpdateHarvestDto extends PartialType(
|
/**
|
||||||
PickType(CreateHarvestDto, [
|
* Every field stays correctable, the apiary and the set of colonies included:
|
||||||
'date',
|
* an Ernte booked against the wrong stand or with a colony forgotten is a
|
||||||
'honeyType',
|
* data-entry error, and re-pointing it is the only way to repair the
|
||||||
'honeySuperCount',
|
* traceability chain without deleting the record.
|
||||||
'grossWeight',
|
*/
|
||||||
'netWeight',
|
export class UpdateHarvestDto extends PartialType(CreateHarvestDto) {}
|
||||||
'notes',
|
|
||||||
] as const),
|
|
||||||
) {}
|
|
||||||
@@ -14,16 +14,19 @@ describe('HarvestsService', () => {
|
|||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let tx: {
|
let tx: {
|
||||||
harvest: { create: jest.Mock };
|
harvest: { create: jest.Mock; update: jest.Mock };
|
||||||
harvestColony: { createMany: jest.Mock };
|
harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock };
|
||||||
};
|
};
|
||||||
|
|
||||||
const harvest = { id: 'harvest-1', apiaryId: 'apiary-1', date: new Date() };
|
const harvest = { id: 'harvest-1', apiaryId: 'apiary-1', date: new Date() };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tx = {
|
tx = {
|
||||||
harvest: { create: jest.fn().mockResolvedValue(harvest) },
|
harvest: {
|
||||||
harvestColony: { createMany: jest.fn() },
|
create: jest.fn().mockResolvedValue(harvest),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
harvestColony: { createMany: jest.fn(), deleteMany: jest.fn() },
|
||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
harvest: { findUnique: jest.fn(), update: jest.fn() },
|
harvest: { findUnique: jest.fn(), update: jest.fn() },
|
||||||
@@ -96,7 +99,6 @@ describe('HarvestsService', () => {
|
|||||||
prisma.harvest.findUnique
|
prisma.harvest.findUnique
|
||||||
.mockResolvedValueOnce(harvest)
|
.mockResolvedValueOnce(harvest)
|
||||||
.mockResolvedValueOnce(updated);
|
.mockResolvedValueOnce(updated);
|
||||||
prisma.harvest.update.mockResolvedValue(updated);
|
|
||||||
|
|
||||||
const result = await service.update(
|
const result = await service.update(
|
||||||
'harvest-1',
|
'harvest-1',
|
||||||
@@ -105,6 +107,7 @@ describe('HarvestsService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual(updated);
|
expect(result).toEqual(updated);
|
||||||
|
expect(tx.harvestColony.deleteMany).not.toHaveBeenCalled();
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
entityType: 'Harvest',
|
entityType: 'Harvest',
|
||||||
@@ -115,4 +118,39 @@ describe('HarvestsService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('update replaces the whole colony set when colonyIds is given', async () => {
|
||||||
|
prisma.harvest.findUnique.mockResolvedValue(harvest);
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
'harvest-1',
|
||||||
|
{ colonyIds: ['colony-1', 'colony-2'] },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.harvestColony.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { harvestId: 'harvest-1' },
|
||||||
|
});
|
||||||
|
const [[createArg]] = tx.harvestColony.createMany.mock.calls as [
|
||||||
|
[{ data: { harvestId: string; colonyId: string }[] }],
|
||||||
|
];
|
||||||
|
expect(createArg.data).toEqual([
|
||||||
|
{ harvestId: 'harvest-1', colonyId: 'colony-1' },
|
||||||
|
{ harvestId: 'harvest-1', colonyId: 'colony-2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update rejects an unknown colony id before touching the database', async () => {
|
||||||
|
prisma.harvest.findUnique.mockResolvedValue(harvest);
|
||||||
|
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update(
|
||||||
|
'harvest-1',
|
||||||
|
{ colonyIds: ['colony-1', 'missing-colony'] },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -93,10 +93,29 @@ export class HarvestsService {
|
|||||||
|
|
||||||
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
|
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
await this.prisma.harvest.update({
|
const { colonyIds, ...scalarFields } = dto;
|
||||||
where: { id },
|
if (colonyIds) {
|
||||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
await this.assertColoniesExist(colonyIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.harvest.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...scalarFields,
|
||||||
|
date: dto.date ? new Date(dto.date) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The join rows carry no data of their own, so replacing the whole set
|
||||||
|
// is both simpler and safer than diffing it.
|
||||||
|
if (colonyIds) {
|
||||||
|
await tx.harvestColony.deleteMany({ where: { harvestId: id } });
|
||||||
|
await tx.harvestColony.createMany({
|
||||||
|
data: colonyIds.map((colonyId) => ({ harvestId: id, colonyId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const harvest = await this.findById(id);
|
const harvest = await this.findById(id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'Harvest',
|
entityType: 'Harvest',
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional } from 'class-validator';
|
||||||
|
import { HoneyBatchStatus } from '../../../generated/prisma';
|
||||||
import { CreateHoneyBatchDto } from './create-honey-batch.dto';
|
import { CreateHoneyBatchDto } from './create-honey-batch.dto';
|
||||||
|
|
||||||
export class UpdateHoneyBatchDto extends PartialType(
|
/**
|
||||||
|
* `storageContainerId` is deliberately absent: moving a batch to another
|
||||||
|
* container runs through POST /honey-batches/:id/relocate, which also writes
|
||||||
|
* the HoneyBatchRelocation entry. A plain PATCH would move the honey without
|
||||||
|
* that record.
|
||||||
|
*/
|
||||||
|
class UpdatableHoneyBatchFieldsDto extends PartialType(
|
||||||
PickType(CreateHoneyBatchDto, [
|
PickType(CreateHoneyBatchDto, [
|
||||||
|
'batchNumber',
|
||||||
|
'extractionId',
|
||||||
'honeyType',
|
'honeyType',
|
||||||
'amount',
|
'amount',
|
||||||
'waterContent',
|
'waterContent',
|
||||||
] as const),
|
] as const),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
|
||||||
|
/**
|
||||||
|
* Set automatically to BOTTLED when the batch is bottled, but a beekeeper
|
||||||
|
* still has to be able to record that a batch was sold or consumed, or to
|
||||||
|
* undo a status that was set by mistake.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ enum: HoneyBatchStatus })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(HoneyBatchStatus)
|
||||||
|
status?: HoneyBatchStatus;
|
||||||
|
}
|
||||||
@@ -1,27 +1,9 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateInspectionDto } from './create-inspection.dto';
|
import { CreateInspectionDto } from './create-inspection.dto';
|
||||||
|
|
||||||
export class UpdateInspectionDto extends PartialType(
|
/**
|
||||||
PickType(CreateInspectionDto, [
|
* Every field stays correctable, `colonyId` included: a record filed against
|
||||||
'date',
|
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||||
'weather',
|
* repair the colony's history without deleting the record.
|
||||||
'temperature',
|
*/
|
||||||
'colonyStrength',
|
export class UpdateInspectionDto extends PartialType(CreateInspectionDto) {}
|
||||||
'broodAmount',
|
|
||||||
'broodPattern',
|
|
||||||
'openBroodPresent',
|
|
||||||
'cappedBroodPresent',
|
|
||||||
'eggsPresent',
|
|
||||||
'queenSeen',
|
|
||||||
'queenCellsPresent',
|
|
||||||
'swarmMood',
|
|
||||||
'gentleness',
|
|
||||||
'combBehavior',
|
|
||||||
'feedStatus',
|
|
||||||
'honeySuperStatus',
|
|
||||||
'healthIssues',
|
|
||||||
'varroaIssues',
|
|
||||||
'measures',
|
|
||||||
'notes',
|
|
||||||
] as const),
|
|
||||||
) {}
|
|
||||||
@@ -3,6 +3,7 @@ import { CreateStorageContainerDto } from './create-storage-container.dto';
|
|||||||
|
|
||||||
export class UpdateStorageContainerDto extends PartialType(
|
export class UpdateStorageContainerDto extends PartialType(
|
||||||
PickType(CreateStorageContainerDto, [
|
PickType(CreateStorageContainerDto, [
|
||||||
|
'code',
|
||||||
'containerType',
|
'containerType',
|
||||||
'capacity',
|
'capacity',
|
||||||
'location',
|
'location',
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
|
import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every field stays correctable, `colonyId` included: a record filed against
|
||||||
|
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||||
|
* repair the colony's history without deleting the record.
|
||||||
|
*/
|
||||||
export class UpdateSwarmControlCheckDto extends PartialType(
|
export class UpdateSwarmControlCheckDto extends PartialType(
|
||||||
PickType(CreateSwarmControlCheckDto, [
|
CreateSwarmControlCheckDto,
|
||||||
'date',
|
|
||||||
'queenCellsFound',
|
|
||||||
'queenCellsCapped',
|
|
||||||
'swarmMood',
|
|
||||||
'actionTaken',
|
|
||||||
'notes',
|
|
||||||
'nextCheckDate',
|
|
||||||
] as const),
|
|
||||||
) {}
|
) {}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
|
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||||
import { TaskPriority, TaskStatus } from '../../../generated/prisma';
|
import {
|
||||||
|
TaskPriority,
|
||||||
|
TaskRelatedType,
|
||||||
|
TaskStatus,
|
||||||
|
} from '../../../generated/prisma';
|
||||||
|
|
||||||
export class UpdateTaskDto {
|
export class UpdateTaskDto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@@ -32,4 +36,14 @@ export class UpdateTaskDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(TaskStatus)
|
@IsEnum(TaskStatus)
|
||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: TaskRelatedType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TaskRelatedType)
|
||||||
|
relatedType?: TaskRelatedType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
relatedId?: string;
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateTreatmentDto } from './create-treatment.dto';
|
import { CreateTreatmentDto } from './create-treatment.dto';
|
||||||
|
|
||||||
export class UpdateTreatmentDto extends PartialType(
|
/**
|
||||||
PickType(CreateTreatmentDto, [
|
* Every field stays correctable, `colonyId` included: a record filed against
|
||||||
'date',
|
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||||
'startDate',
|
* repair the colony's history without deleting the record.
|
||||||
'endDate',
|
*/
|
||||||
'product',
|
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto) {}
|
||||||
'activeIngredient',
|
|
||||||
'amount',
|
|
||||||
'method',
|
|
||||||
'reason',
|
|
||||||
'batchNumber',
|
|
||||||
'notes',
|
|
||||||
'withdrawalPeriodDays',
|
|
||||||
] as const),
|
|
||||||
) {}
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
|
import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every field stays correctable, `colonyId` included: a record filed against
|
||||||
|
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||||
|
* repair the colony's history without deleting the record.
|
||||||
|
*/
|
||||||
export class UpdateVarroaMeasurementDto extends PartialType(
|
export class UpdateVarroaMeasurementDto extends PartialType(
|
||||||
PickType(CreateVarroaMeasurementDto, [
|
CreateVarroaMeasurementDto,
|
||||||
'date',
|
|
||||||
'method',
|
|
||||||
'durationMinutes',
|
|
||||||
'miteCount',
|
|
||||||
'calculatedValue',
|
|
||||||
'assessment',
|
|
||||||
'notes',
|
|
||||||
] as const),
|
|
||||||
) {}
|
) {}
|
||||||
@@ -76,6 +76,7 @@ function ConditionSelect({
|
|||||||
|
|
||||||
export function ColonyEditCard({ colony }: { colony: Colony }) {
|
export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [code, setCode] = useState(colony.code);
|
||||||
const [status, setStatus] = useState<ColonyStatus>(colony.status);
|
const [status, setStatus] = useState<ColonyStatus>(colony.status);
|
||||||
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
|
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
|
||||||
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
|
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
|
||||||
@@ -95,6 +96,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
|||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateColony(colony.id, {
|
updateColony(colony.id, {
|
||||||
|
code,
|
||||||
status,
|
status,
|
||||||
colonyType,
|
colonyType,
|
||||||
hiveType: hiveType || undefined,
|
hiveType: hiveType || undefined,
|
||||||
@@ -133,6 +135,15 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="colony-code">Volksnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="colony-code"
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="colony-status">Status</Label>
|
<Label htmlFor="colony-status">Status</Label>
|
||||||
<Select value={status} onValueChange={(value) => setStatus(value as ColonyStatus)}>
|
<Select value={status} onValueChange={(value) => setStatus(value as ColonyStatus)}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { ColonySelectField } from "@/components/colony-select-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [colonyId, setColonyId] = useState(feeding.colonyId);
|
||||||
const [date, setDate] = useState(feeding.date.slice(0, 10));
|
const [date, setDate] = useState(feeding.date.slice(0, 10));
|
||||||
const [feedType, setFeedType] = useState<FeedType>(feeding.feedType);
|
const [feedType, setFeedType] = useState<FeedType>(feeding.feedType);
|
||||||
const [product, setProduct] = useState(feeding.product ?? "");
|
const [product, setProduct] = useState(feeding.product ?? "");
|
||||||
@@ -40,6 +42,7 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateFeeding(feeding.id, {
|
updateFeeding(feeding.id, {
|
||||||
|
colonyId,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
feedType,
|
feedType,
|
||||||
product: product || undefined,
|
product: product || undefined,
|
||||||
@@ -49,9 +52,13 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
|||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({
|
// After re-filing, both the colony it left and the one it joined
|
||||||
queryKey: ["colonies", feeding.colonyId, "feedings"],
|
// show a stale list until each is invalidated.
|
||||||
});
|
for (const affectedColonyId of new Set([feeding.colonyId, colonyId])) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["colonies", affectedColonyId, "feedings"],
|
||||||
|
});
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Fütterung konnte nicht gespeichert werden"),
|
onError: () => toast.error("Fütterung konnte nicht gespeichert werden"),
|
||||||
@@ -77,6 +84,11 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
|
|||||||
<DialogTitle>Fütterung bearbeiten</DialogTitle>
|
<DialogTitle>Fütterung bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<ColonySelectField
|
||||||
|
id="feeding-edit-colony"
|
||||||
|
value={colonyId}
|
||||||
|
onChange={setColonyId}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="feeding-edit-date">Datum</Label>
|
<Label htmlFor="feeding-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { ColonySelectField } from "@/components/colony-select-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
|
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [colonyId, setColonyId] = useState(inspection.colonyId);
|
||||||
const [date, setDate] = useState(inspection.date.slice(0, 10));
|
const [date, setDate] = useState(inspection.date.slice(0, 10));
|
||||||
const [weather, setWeather] = useState(inspection.weather ?? "");
|
const [weather, setWeather] = useState(inspection.weather ?? "");
|
||||||
const [temperature, setTemperature] = useState(inspection.temperature?.toString() ?? "");
|
const [temperature, setTemperature] = useState(inspection.temperature?.toString() ?? "");
|
||||||
@@ -64,6 +66,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateInspection(inspection.id, {
|
updateInspection(inspection.id, {
|
||||||
|
colonyId,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
weather: weather || undefined,
|
weather: weather || undefined,
|
||||||
temperature: temperature ? Number(temperature) : undefined,
|
temperature: temperature ? Number(temperature) : undefined,
|
||||||
@@ -86,9 +89,13 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
|||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({
|
// After re-filing, both the colony it left and the one it joined
|
||||||
queryKey: ["colonies", inspection.colonyId, "inspections"],
|
// show a stale list until each is invalidated.
|
||||||
});
|
for (const affectedColonyId of new Set([inspection.colonyId, colonyId])) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["colonies", affectedColonyId, "inspections"],
|
||||||
|
});
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Durchsicht konnte nicht gespeichert werden"),
|
onError: () => toast.error("Durchsicht konnte nicht gespeichert werden"),
|
||||||
@@ -114,6 +121,11 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
|
|||||||
<DialogTitle>Durchsicht bearbeiten</DialogTitle>
|
<DialogTitle>Durchsicht bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<ColonySelectField
|
||||||
|
id="inspection-edit-colony"
|
||||||
|
value={colonyId}
|
||||||
|
onChange={setColonyId}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="inspection-edit-date">Datum</Label>
|
<Label htmlFor="inspection-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { ColonySelectField } from "@/components/colony-select-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
|
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [colonyId, setColonyId] = useState(check.colonyId);
|
||||||
const [date, setDate] = useState(check.date.slice(0, 10));
|
const [date, setDate] = useState(check.date.slice(0, 10));
|
||||||
const [queenCellsFound, setQueenCellsFound] = useState(check.queenCellsFound?.toString() ?? "");
|
const [queenCellsFound, setQueenCellsFound] = useState(check.queenCellsFound?.toString() ?? "");
|
||||||
const [queenCellsCapped, setQueenCellsCapped] = useState(check.queenCellsCapped);
|
const [queenCellsCapped, setQueenCellsCapped] = useState(check.queenCellsCapped);
|
||||||
@@ -40,6 +42,7 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateSwarmControlCheck(check.id, {
|
updateSwarmControlCheck(check.id, {
|
||||||
|
colonyId,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
|
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
|
||||||
queenCellsCapped,
|
queenCellsCapped,
|
||||||
@@ -49,9 +52,13 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
|||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({
|
// After re-filing, both the colony it left and the one it joined
|
||||||
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
|
// show a stale list until each is invalidated.
|
||||||
});
|
for (const affectedColonyId of new Set([check.colonyId, colonyId])) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["colonies", affectedColonyId, "swarm-control-checks"],
|
||||||
|
});
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Schwarmkontrolle konnte nicht gespeichert werden"),
|
onError: () => toast.error("Schwarmkontrolle konnte nicht gespeichert werden"),
|
||||||
@@ -77,6 +84,11 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
|||||||
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
|
<DialogTitle>Schwarmkontrolle bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<ColonySelectField
|
||||||
|
id="swarm-edit-colony"
|
||||||
|
value={colonyId}
|
||||||
|
onChange={setColonyId}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="swarm-edit-date">Datum</Label>
|
<Label htmlFor="swarm-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ColonySelectField } from "@/components/colony-select-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [colonyId, setColonyId] = useState(treatment.colonyId);
|
||||||
const [date, setDate] = useState(treatment.date.slice(0, 10));
|
const [date, setDate] = useState(treatment.date.slice(0, 10));
|
||||||
const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? "");
|
const [startDate, setStartDate] = useState(treatment.startDate?.slice(0, 10) ?? "");
|
||||||
const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? "");
|
const [endDate, setEndDate] = useState(treatment.endDate?.slice(0, 10) ?? "");
|
||||||
@@ -38,6 +40,7 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateTreatment(treatment.id, {
|
updateTreatment(treatment.id, {
|
||||||
|
colonyId,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
startDate: startDate ? new Date(startDate).toISOString() : undefined,
|
startDate: startDate ? new Date(startDate).toISOString() : undefined,
|
||||||
endDate: endDate ? new Date(endDate).toISOString() : undefined,
|
endDate: endDate ? new Date(endDate).toISOString() : undefined,
|
||||||
@@ -51,9 +54,13 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
|||||||
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
|
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({
|
// After re-filing, both the colony it left and the one it joined
|
||||||
queryKey: ["colonies", treatment.colonyId, "treatments"],
|
// show a stale list until each is invalidated.
|
||||||
});
|
for (const affectedColonyId of new Set([treatment.colonyId, colonyId])) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["colonies", affectedColonyId, "treatments"],
|
||||||
|
});
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Behandlung konnte nicht gespeichert werden"),
|
onError: () => toast.error("Behandlung konnte nicht gespeichert werden"),
|
||||||
@@ -79,6 +86,11 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
|||||||
<DialogTitle>Behandlung bearbeiten</DialogTitle>
|
<DialogTitle>Behandlung bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<ColonySelectField
|
||||||
|
id="treatment-edit-colony"
|
||||||
|
value={colonyId}
|
||||||
|
onChange={setColonyId}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="treatment-edit-date">Datum</Label>
|
<Label htmlFor="treatment-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { ColonySelectField } from "@/components/colony-select-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
|
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [colonyId, setColonyId] = useState(measurement.colonyId);
|
||||||
const [date, setDate] = useState(measurement.date.slice(0, 10));
|
const [date, setDate] = useState(measurement.date.slice(0, 10));
|
||||||
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
|
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
|
||||||
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
|
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
|
||||||
@@ -44,6 +46,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateVarroaMeasurement(measurement.id, {
|
updateVarroaMeasurement(measurement.id, {
|
||||||
|
colonyId,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
method,
|
method,
|
||||||
miteCount: miteCount ? Number(miteCount) : undefined,
|
miteCount: miteCount ? Number(miteCount) : undefined,
|
||||||
@@ -53,9 +56,13 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({
|
// After re-filing, both the colony it left and the one it joined
|
||||||
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
|
// show a stale list until each is invalidated.
|
||||||
});
|
for (const affectedColonyId of new Set([measurement.colonyId, colonyId])) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["colonies", affectedColonyId, "varroa-measurements"],
|
||||||
|
});
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Varroamessung konnte nicht gespeichert werden"),
|
onError: () => toast.error("Varroamessung konnte nicht gespeichert werden"),
|
||||||
@@ -81,6 +88,11 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
<DialogTitle>Varroamessung bearbeiten</DialogTitle>
|
<DialogTitle>Varroamessung bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<ColonySelectField
|
||||||
|
id="varroa-edit-colony"
|
||||||
|
value={colonyId}
|
||||||
|
onChange={setColonyId}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="varroa-edit-date">Datum</Label>
|
<Label htmlFor="varroa-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { HarvestSelectField } from "@/components/harvest-select-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,6 +22,9 @@ import {
|
|||||||
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
|
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [harvestIds, setHarvestIds] = useState<string[]>(
|
||||||
|
extraction.harvests.map((entry) => entry.harvestId),
|
||||||
|
);
|
||||||
const [date, setDate] = useState(extraction.date.slice(0, 10));
|
const [date, setDate] = useState(extraction.date.slice(0, 10));
|
||||||
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
|
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
|
||||||
const [room, setRoom] = useState(extraction.room ?? "");
|
const [room, setRoom] = useState(extraction.room ?? "");
|
||||||
@@ -31,6 +35,7 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateExtraction(extraction.id, {
|
updateExtraction(extraction.id, {
|
||||||
|
harvestIds,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
amount: amount ? Number(amount) : undefined,
|
amount: amount ? Number(amount) : undefined,
|
||||||
room: room || undefined,
|
room: room || undefined,
|
||||||
@@ -42,11 +47,16 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
|
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Schleuderung konnte nicht gespeichert werden"),
|
onError: (error: Error) =>
|
||||||
|
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (harvestIds.length === 0) {
|
||||||
|
toast.error("Bitte mindestens eine Ernte auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
mutation.mutate();
|
mutation.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +75,11 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
|
|||||||
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
|
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<HarvestSelectField
|
||||||
|
idPrefix="extraction-edit-harvest"
|
||||||
|
value={harvestIds}
|
||||||
|
onChange={setHarvestIds}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="extraction-edit-date">Datum</Label>
|
<Label htmlFor="extraction-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -2,14 +2,23 @@
|
|||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateHarvest } from "@/lib/harvests/api";
|
import { updateHarvest } from "@/lib/harvests/api";
|
||||||
import { Harvest } from "@/lib/harvests/types";
|
import { Harvest } from "@/lib/harvests/types";
|
||||||
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,6 +30,10 @@ import {
|
|||||||
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [apiaryId, setApiaryId] = useState(harvest.apiaryId);
|
||||||
|
const [colonyIds, setColonyIds] = useState<string[]>(
|
||||||
|
harvest.colonies.map((entry) => entry.colonyId),
|
||||||
|
);
|
||||||
const [date, setDate] = useState(harvest.date.slice(0, 10));
|
const [date, setDate] = useState(harvest.date.slice(0, 10));
|
||||||
const [honeyType, setHoneyType] = useState(harvest.honeyType ?? "");
|
const [honeyType, setHoneyType] = useState(harvest.honeyType ?? "");
|
||||||
const [grossWeight, setGrossWeight] = useState(harvest.grossWeight?.toString() ?? "");
|
const [grossWeight, setGrossWeight] = useState(harvest.grossWeight?.toString() ?? "");
|
||||||
@@ -30,9 +43,35 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
|||||||
);
|
);
|
||||||
const [notes, setNotes] = useState(harvest.notes ?? "");
|
const [notes, setNotes] = useState(harvest.notes ?? "");
|
||||||
|
|
||||||
|
const { data: apiaries } = useQuery({
|
||||||
|
queryKey: ["apiaries"],
|
||||||
|
queryFn: () => fetchApiaries(),
|
||||||
|
});
|
||||||
|
const { data: colonies } = useQuery({
|
||||||
|
queryKey: ["colonies"],
|
||||||
|
queryFn: () => fetchColonies(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Colonies of the chosen apiary, plus any colony already on this harvest -
|
||||||
|
// one that was meanwhile moved or dissolved must stay visible, otherwise
|
||||||
|
// saving would silently drop it from the harvest.
|
||||||
|
const selectableColonies = (colonies ?? []).filter(
|
||||||
|
(colony) => colony.apiaryId === apiaryId || colonyIds.includes(colony.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleColony(colonyId: string, checked: boolean) {
|
||||||
|
setColonyIds((current) =>
|
||||||
|
checked
|
||||||
|
? [...current, colonyId]
|
||||||
|
: current.filter((id) => id !== colonyId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateHarvest(harvest.id, {
|
updateHarvest(harvest.id, {
|
||||||
|
apiaryId,
|
||||||
|
colonyIds,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
honeyType: honeyType || undefined,
|
honeyType: honeyType || undefined,
|
||||||
grossWeight: grossWeight ? Number(grossWeight) : undefined,
|
grossWeight: grossWeight ? Number(grossWeight) : undefined,
|
||||||
@@ -41,14 +80,19 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
|||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["harvests", harvest.id] });
|
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Ernte konnte nicht gespeichert werden"),
|
onError: (error: Error) =>
|
||||||
|
toast.error(error.message || "Ernte konnte nicht gespeichert werden"),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (colonyIds.length === 0) {
|
||||||
|
toast.error("Bitte mindestens ein Volk auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
mutation.mutate();
|
mutation.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +111,48 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
|||||||
<DialogTitle>Ernte bearbeiten</DialogTitle>
|
<DialogTitle>Ernte bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="harvest-edit-apiary">Bienenstand</Label>
|
||||||
|
<Select
|
||||||
|
value={apiaryId}
|
||||||
|
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="harvest-edit-apiary" className="w-full">
|
||||||
|
<SelectValue placeholder="Bienenstand wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(apiaries ?? []).map((apiary) => (
|
||||||
|
<SelectItem key={apiary.id} value={apiary.id}>
|
||||||
|
{apiary.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Beteiligte Völker</Label>
|
||||||
|
{selectableColonies.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Keine Völker an diesem Bienenstand.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{selectableColonies.map((colony) => (
|
||||||
|
<label
|
||||||
|
key={colony.id}
|
||||||
|
className="flex h-11 items-center gap-3 text-sm"
|
||||||
|
htmlFor={`harvest-edit-colony-${colony.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={`harvest-edit-colony-${colony.id}`}
|
||||||
|
type="checkbox"
|
||||||
|
className="size-5"
|
||||||
|
checked={colonyIds.includes(colony.id)}
|
||||||
|
onChange={(event) => toggleColony(colony.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
{colony.code}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="harvest-edit-date">Datum</Label>
|
<Label htmlFor="harvest-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { HarvestSelectField } from "@/components/harvest-select-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -28,6 +29,9 @@ function ExtractionEditForm({
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [harvestIds, setHarvestIds] = useState<string[]>(
|
||||||
|
extraction.harvests.map((entry) => entry.harvestId),
|
||||||
|
);
|
||||||
const [date, setDate] = useState(extraction.date.slice(0, 10));
|
const [date, setDate] = useState(extraction.date.slice(0, 10));
|
||||||
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
|
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
|
||||||
const [room, setRoom] = useState(extraction.room ?? "");
|
const [room, setRoom] = useState(extraction.room ?? "");
|
||||||
@@ -42,6 +46,7 @@ function ExtractionEditForm({
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateExtraction(extraction.id, {
|
updateExtraction(extraction.id, {
|
||||||
|
harvestIds,
|
||||||
date: new Date(date).toISOString(),
|
date: new Date(date).toISOString(),
|
||||||
amount: amount ? Number(amount) : undefined,
|
amount: amount ? Number(amount) : undefined,
|
||||||
room: room || undefined,
|
room: room || undefined,
|
||||||
@@ -55,16 +60,26 @@ function ExtractionEditForm({
|
|||||||
});
|
});
|
||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Schleuderung konnte nicht gespeichert werden"),
|
onError: (error: Error) =>
|
||||||
|
toast.error(error.message || "Schleuderung konnte nicht gespeichert werden"),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (harvestIds.length === 0) {
|
||||||
|
toast.error("Bitte mindestens eine Ernte auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
mutation.mutate();
|
mutation.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<HarvestSelectField
|
||||||
|
idPrefix="batch-extraction-edit-harvest"
|
||||||
|
value={harvestIds}
|
||||||
|
onChange={setHarvestIds}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="extraction-edit-date">Datum</Label>
|
<Label htmlFor="extraction-edit-date">Datum</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -2,13 +2,22 @@
|
|||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateHoneyBatch } from "@/lib/honey-batches/api";
|
import { updateHoneyBatch } from "@/lib/honey-batches/api";
|
||||||
import { HoneyBatch } from "@/lib/honey-batches/types";
|
import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
|
||||||
|
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
|
||||||
|
import { fetchExtractions } from "@/lib/extractions/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,13 +30,24 @@ import {
|
|||||||
export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) {
|
export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [batchNumber, setBatchNumber] = useState(honeyBatch.batchNumber);
|
||||||
|
const [extractionId, setExtractionId] = useState(honeyBatch.extractionId);
|
||||||
|
const [status, setStatus] = useState<HoneyBatchStatus>(honeyBatch.status);
|
||||||
const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? "");
|
const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? "");
|
||||||
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
|
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
|
||||||
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
|
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
|
||||||
|
|
||||||
|
const { data: extractions } = useQuery({
|
||||||
|
queryKey: ["extractions"],
|
||||||
|
queryFn: () => fetchExtractions(),
|
||||||
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateHoneyBatch(honeyBatch.id, {
|
updateHoneyBatch(honeyBatch.id, {
|
||||||
|
batchNumber,
|
||||||
|
extractionId,
|
||||||
|
status,
|
||||||
honeyType: honeyType || undefined,
|
honeyType: honeyType || undefined,
|
||||||
amount: amount ? Number(amount) : undefined,
|
amount: amount ? Number(amount) : undefined,
|
||||||
waterContent: waterContent ? Number(waterContent) : undefined,
|
waterContent: waterContent ? Number(waterContent) : undefined,
|
||||||
@@ -60,6 +80,54 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
|
|||||||
<DialogTitle>Honigcharge bearbeiten</DialogTitle>
|
<DialogTitle>Honigcharge bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="batch-edit-number">Chargennummer</Label>
|
||||||
|
<Input
|
||||||
|
id="batch-edit-number"
|
||||||
|
required
|
||||||
|
value={batchNumber}
|
||||||
|
onChange={(event) => setBatchNumber(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="batch-edit-extraction">Schleuderung</Label>
|
||||||
|
<Select
|
||||||
|
value={extractionId}
|
||||||
|
onValueChange={(value) => setExtractionId(value ?? extractionId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="batch-edit-extraction" className="w-full">
|
||||||
|
<SelectValue placeholder="Schleuderung wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(extractions ?? []).map((extraction) => (
|
||||||
|
<SelectItem key={extraction.id} value={extraction.id}>
|
||||||
|
{new Date(extraction.date).toLocaleDateString("de-DE")}
|
||||||
|
{extraction.amount !== null ? ` · ${extraction.amount} kg` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="batch-edit-status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) => setStatus((value ?? status) as HoneyBatchStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="batch-edit-status" className="w-full">
|
||||||
|
<SelectValue>{HONEY_BATCH_STATUS_LABELS[status]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(
|
||||||
|
Object.keys(HONEY_BATCH_STATUS_LABELS) as HoneyBatchStatus[]
|
||||||
|
).map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{HONEY_BATCH_STATUS_LABELS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
|
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
export function EditStorageContainerDialog({ container }: { container: StorageContainer }) {
|
export function EditStorageContainerDialog({ container }: { container: StorageContainer }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [code, setCode] = useState(container.code);
|
||||||
const [containerType, setContainerType] = useState(container.containerType ?? "");
|
const [containerType, setContainerType] = useState(container.containerType ?? "");
|
||||||
const [capacity, setCapacity] = useState(container.capacity?.toString() ?? "");
|
const [capacity, setCapacity] = useState(container.capacity?.toString() ?? "");
|
||||||
const [location, setLocation] = useState(container.location ?? "");
|
const [location, setLocation] = useState(container.location ?? "");
|
||||||
@@ -28,6 +29,7 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateStorageContainer(container.id, {
|
updateStorageContainer(container.id, {
|
||||||
|
code,
|
||||||
containerType: containerType || undefined,
|
containerType: containerType || undefined,
|
||||||
capacity: capacity ? Number(capacity) : undefined,
|
capacity: capacity ? Number(capacity) : undefined,
|
||||||
location: location || undefined,
|
location: location || undefined,
|
||||||
@@ -59,6 +61,15 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo
|
|||||||
<DialogTitle>Lagerbehälter bearbeiten</DialogTitle>
|
<DialogTitle>Lagerbehälter bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="container-edit-code">Code</Label>
|
||||||
|
<Input
|
||||||
|
id="container-edit-code"
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="container-edit-type">Typ</Label>
|
<Label htmlFor="container-edit-type">Typ</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { Pencil } from "lucide-react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateTask } from "@/lib/tasks/api";
|
import { updateTask } from "@/lib/tasks/api";
|
||||||
import { Task, TaskPriority } from "@/lib/tasks/types";
|
import { Task, TaskPriority, TaskRelatedType } from "@/lib/tasks/types";
|
||||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||||
import { fetchUsers } from "@/lib/users/api";
|
import { fetchUsers } from "@/lib/users/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { TaskRelatedField } from "@/components/task-related-field";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -35,6 +36,8 @@ export function TaskEditDialog({ task }: { task: Task }) {
|
|||||||
const [priority, setPriority] = useState<TaskPriority>(task.priority);
|
const [priority, setPriority] = useState<TaskPriority>(task.priority);
|
||||||
const [dueDate, setDueDate] = useState(task.dueDate?.slice(0, 10) ?? "");
|
const [dueDate, setDueDate] = useState(task.dueDate?.slice(0, 10) ?? "");
|
||||||
const [assignedUserId, setAssignedUserId] = useState(task.assignedUserId ?? "");
|
const [assignedUserId, setAssignedUserId] = useState(task.assignedUserId ?? "");
|
||||||
|
const [relatedType, setRelatedType] = useState<TaskRelatedType>(task.relatedType);
|
||||||
|
const [relatedId, setRelatedId] = useState(task.relatedId ?? "");
|
||||||
|
|
||||||
// Assigning a task requires users:read, which not every role has - if the
|
// Assigning a task requires users:read, which not every role has - if the
|
||||||
// request fails, the assignee field below is simply omitted rather than
|
// request fails, the assignee field below is simply omitted rather than
|
||||||
@@ -53,6 +56,8 @@ export function TaskEditDialog({ task }: { task: Task }) {
|
|||||||
priority,
|
priority,
|
||||||
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||||
assignedUserId: assignedUserId || undefined,
|
assignedUserId: assignedUserId || undefined,
|
||||||
|
relatedType,
|
||||||
|
relatedId: relatedType === "GENERAL" ? undefined : relatedId || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
@@ -133,6 +138,13 @@ export function TaskEditDialog({ task }: { task: Task }) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<TaskRelatedField
|
||||||
|
idPrefix="task-edit-related"
|
||||||
|
relatedType={relatedType}
|
||||||
|
relatedId={relatedId}
|
||||||
|
onRelatedTypeChange={setRelatedType}
|
||||||
|
onRelatedIdChange={setRelatedId}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="task-edit-due">Fällig am</Label>
|
<Label htmlFor="task-edit-due">Fällig am</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface ColonySelectFieldProps {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (colonyId: string) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the colony a record belongs to. Every colony-scoped record
|
||||||
|
* (inspection, treatment, feeding, varroa measurement, swarm control check)
|
||||||
|
* can be re-filed under a different colony after the fact, because filing one
|
||||||
|
* against the wrong colony is a data-entry error rather than a fact of
|
||||||
|
* beekeeping - and the only alternative repair would be deleting the record.
|
||||||
|
*
|
||||||
|
* Dissolved colonies stay in the list: a record already filed against one has
|
||||||
|
* to remain visible and selectable, otherwise opening the form would silently
|
||||||
|
* re-file it somewhere else.
|
||||||
|
*/
|
||||||
|
export function ColonySelectField({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = "Volk",
|
||||||
|
}: ColonySelectFieldProps) {
|
||||||
|
const { data: colonies } = useQuery({
|
||||||
|
queryKey: ["colonies"],
|
||||||
|
queryFn: () => fetchColonies(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
<Select value={value} onValueChange={(next) => onChange(next ?? value)}>
|
||||||
|
<SelectTrigger id={id} className="w-full">
|
||||||
|
<SelectValue placeholder="Volk wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(colonies ?? []).map((colony) => (
|
||||||
|
<SelectItem key={colony.id} value={colony.id}>
|
||||||
|
{colony.code}
|
||||||
|
{colony.status === "ACTIVE" ? "" : " (aufgelöst)"}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchHarvests } from "@/lib/harvests/api";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
interface HarvestSelectFieldProps {
|
||||||
|
idPrefix: string;
|
||||||
|
value: string[];
|
||||||
|
onChange: (harvestIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the harvests an extraction was made from. The set stays editable after
|
||||||
|
* the fact: an extraction linked to the wrong harvest breaks the traceability
|
||||||
|
* chain from colony to jar, and re-pointing it is the only repair that does
|
||||||
|
* not require deleting the extraction and everything downstream of it.
|
||||||
|
*/
|
||||||
|
export function HarvestSelectField({
|
||||||
|
idPrefix,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: HarvestSelectFieldProps) {
|
||||||
|
const { data: harvests } = useQuery({
|
||||||
|
queryKey: ["harvests"],
|
||||||
|
queryFn: () => fetchHarvests(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle(harvestId: string, checked: boolean) {
|
||||||
|
onChange(
|
||||||
|
checked ? [...value, harvestId] : value.filter((id) => id !== harvestId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Ernten</Label>
|
||||||
|
{(harvests ?? []).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Keine Ernten erfasst.</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex max-h-48 flex-col gap-2 overflow-y-auto">
|
||||||
|
{(harvests ?? []).map((harvest) => (
|
||||||
|
<label
|
||||||
|
key={harvest.id}
|
||||||
|
className="flex min-h-11 items-center gap-3 text-sm"
|
||||||
|
htmlFor={`${idPrefix}-${harvest.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-${harvest.id}`}
|
||||||
|
type="checkbox"
|
||||||
|
className="size-5 shrink-0"
|
||||||
|
checked={value.includes(harvest.id)}
|
||||||
|
onChange={(event) => toggle(harvest.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||||
|
{harvest.netWeight !== null ? ` · ${harvest.netWeight} kg` : ""}
|
||||||
|
{harvest.colonies.length > 0
|
||||||
|
? ` · ${harvest.colonies.map((entry) => entry.colony.code).join(", ")}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { TaskRelatedType } from "@/lib/tasks/types";
|
||||||
|
import {
|
||||||
|
TASK_RELATED_TYPES,
|
||||||
|
TASK_RELATED_TYPE_LABELS,
|
||||||
|
} from "@/lib/tasks/labels";
|
||||||
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
|
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||||
|
import { fetchQueens } from "@/lib/queens/api";
|
||||||
|
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface RelatedOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskRelatedFieldProps {
|
||||||
|
idPrefix: string;
|
||||||
|
relatedType: TaskRelatedType;
|
||||||
|
relatedId: string;
|
||||||
|
onRelatedTypeChange: (relatedType: TaskRelatedType) => void;
|
||||||
|
onRelatedIdChange: (relatedId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks what a task refers to. The reference is polymorphic, so the second
|
||||||
|
* select only loads the list matching the chosen type, and GENERAL has no
|
||||||
|
* record to point at at all.
|
||||||
|
*
|
||||||
|
* A task created from an inspection carries the reference automatically, but
|
||||||
|
* it stays editable: a task filed against the wrong colony sends a beekeeper
|
||||||
|
* to the wrong hive.
|
||||||
|
*/
|
||||||
|
export function TaskRelatedField({
|
||||||
|
idPrefix,
|
||||||
|
relatedType,
|
||||||
|
relatedId,
|
||||||
|
onRelatedTypeChange,
|
||||||
|
onRelatedIdChange,
|
||||||
|
}: TaskRelatedFieldProps) {
|
||||||
|
const colonies = useQuery({
|
||||||
|
queryKey: ["colonies"],
|
||||||
|
queryFn: () => fetchColonies(),
|
||||||
|
enabled: relatedType === "COLONY",
|
||||||
|
});
|
||||||
|
const apiaries = useQuery({
|
||||||
|
queryKey: ["apiaries"],
|
||||||
|
queryFn: () => fetchApiaries(),
|
||||||
|
enabled: relatedType === "APIARY",
|
||||||
|
});
|
||||||
|
const queens = useQuery({
|
||||||
|
queryKey: ["queens"],
|
||||||
|
queryFn: () => fetchQueens(),
|
||||||
|
enabled: relatedType === "QUEEN",
|
||||||
|
});
|
||||||
|
const honeyBatches = useQuery({
|
||||||
|
queryKey: ["honey-batches"],
|
||||||
|
queryFn: () => fetchHoneyBatches(),
|
||||||
|
enabled: relatedType === "HONEY_BATCH",
|
||||||
|
});
|
||||||
|
const containers = useQuery({
|
||||||
|
queryKey: ["storage-containers"],
|
||||||
|
queryFn: () => fetchStorageContainers(),
|
||||||
|
enabled: relatedType === "STORAGE",
|
||||||
|
});
|
||||||
|
|
||||||
|
const optionsByType: Record<TaskRelatedType, RelatedOption[]> = {
|
||||||
|
COLONY: (colonies.data ?? []).map((colony) => ({
|
||||||
|
id: colony.id,
|
||||||
|
label: colony.code,
|
||||||
|
})),
|
||||||
|
APIARY: (apiaries.data ?? []).map((apiary) => ({
|
||||||
|
id: apiary.id,
|
||||||
|
label: apiary.name,
|
||||||
|
})),
|
||||||
|
QUEEN: (queens.data ?? []).map((queen) => ({
|
||||||
|
id: queen.id,
|
||||||
|
label: queen.code,
|
||||||
|
})),
|
||||||
|
HONEY_BATCH: (honeyBatches.data ?? []).map((honeyBatch) => ({
|
||||||
|
id: honeyBatch.id,
|
||||||
|
label: honeyBatch.batchNumber,
|
||||||
|
})),
|
||||||
|
STORAGE: (containers.data ?? []).map((container) => ({
|
||||||
|
id: container.id,
|
||||||
|
label: container.code,
|
||||||
|
})),
|
||||||
|
GENERAL: [],
|
||||||
|
};
|
||||||
|
const options = optionsByType[relatedType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`${idPrefix}-type`}>Bezug</Label>
|
||||||
|
<Select
|
||||||
|
value={relatedType}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = (value ?? relatedType) as TaskRelatedType;
|
||||||
|
onRelatedTypeChange(next);
|
||||||
|
// The old id belongs to the old type's records, so it would point
|
||||||
|
// at nothing after the switch.
|
||||||
|
onRelatedIdChange("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
|
||||||
|
<SelectValue>{TASK_RELATED_TYPE_LABELS[relatedType]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TASK_RELATED_TYPES.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{TASK_RELATED_TYPE_LABELS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{relatedType === "GENERAL" ? null : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`${idPrefix}-record`}>
|
||||||
|
{TASK_RELATED_TYPE_LABELS[relatedType]}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={relatedId}
|
||||||
|
onValueChange={(value) => onRelatedIdChange(value ?? relatedId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`${idPrefix}-record`} className="w-full">
|
||||||
|
<SelectValue placeholder="Datensatz wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export interface CreateColonyInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateColonyInput {
|
export interface UpdateColonyInput {
|
||||||
|
code?: string;
|
||||||
hiveType?: string;
|
hiveType?: string;
|
||||||
originText?: string;
|
originText?: string;
|
||||||
colonyType?: ColonyType;
|
colonyType?: ColonyType;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface CreateExtractionInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateExtractionInput {
|
export interface UpdateExtractionInput {
|
||||||
|
/** Replaces the whole set of source harvests. */
|
||||||
|
harvestIds?: string[];
|
||||||
date?: string;
|
date?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
room?: string;
|
room?: string;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface CreateFeedingInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFeedingInput {
|
export interface UpdateFeedingInput {
|
||||||
|
/** Re-files the feeding under a different colony. */
|
||||||
|
colonyId?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
feedType?: FeedType;
|
feedType?: FeedType;
|
||||||
product?: string;
|
product?: string;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export interface CreateHarvestInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateHarvestInput {
|
export interface UpdateHarvestInput {
|
||||||
|
apiaryId?: string;
|
||||||
|
/** Replaces the whole set of participating colonies. */
|
||||||
|
colonyIds?: string[];
|
||||||
date?: string;
|
date?: string;
|
||||||
honeyType?: string;
|
honeyType?: string;
|
||||||
honeySuperCount?: number;
|
honeySuperCount?: number;
|
||||||
|
|||||||
@@ -90,9 +90,12 @@ export interface CreateHoneyBatchInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateHoneyBatchInput {
|
export interface UpdateHoneyBatchInput {
|
||||||
|
batchNumber?: string;
|
||||||
|
extractionId?: string;
|
||||||
honeyType?: string;
|
honeyType?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
waterContent?: number;
|
waterContent?: number;
|
||||||
|
status?: HoneyBatchStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateHoneyMeasurementInput {
|
export interface CreateHoneyMeasurementInput {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface CreateInspectionInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateInspectionInput {
|
export interface UpdateInspectionInput {
|
||||||
|
/** Re-files the inspection under a different colony. */
|
||||||
|
colonyId?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
weather?: string;
|
weather?: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface CreateStorageContainerInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStorageContainerInput {
|
export interface UpdateStorageContainerInput {
|
||||||
|
code?: string;
|
||||||
containerType?: string;
|
containerType?: string;
|
||||||
capacity?: number;
|
capacity?: number;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface CreateSwarmControlCheckInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSwarmControlCheckInput {
|
export interface UpdateSwarmControlCheckInput {
|
||||||
|
/** Re-files the check under a different colony. */
|
||||||
|
colonyId?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
queenCellsFound?: number;
|
queenCellsFound?: number;
|
||||||
queenCellsCapped?: boolean;
|
queenCellsCapped?: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TaskPriority, TaskStatus } from "./types";
|
import { TaskPriority, TaskRelatedType, TaskStatus } from "./types";
|
||||||
|
|
||||||
export const TASK_STATUSES: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
|
export const TASK_STATUSES: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
|
||||||
|
|
||||||
@@ -17,3 +17,21 @@ export const TASK_PRIORITY_LABELS: Record<TaskPriority, string> = {
|
|||||||
HIGH: "Hoch",
|
HIGH: "Hoch",
|
||||||
URGENT: "Dringend",
|
URGENT: "Dringend",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TASK_RELATED_TYPES: TaskRelatedType[] = [
|
||||||
|
"GENERAL",
|
||||||
|
"COLONY",
|
||||||
|
"APIARY",
|
||||||
|
"QUEEN",
|
||||||
|
"HONEY_BATCH",
|
||||||
|
"STORAGE",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TASK_RELATED_TYPE_LABELS: Record<TaskRelatedType, string> = {
|
||||||
|
GENERAL: "Allgemein",
|
||||||
|
COLONY: "Volk",
|
||||||
|
APIARY: "Bienenstand",
|
||||||
|
QUEEN: "Königin",
|
||||||
|
HONEY_BATCH: "Honigcharge",
|
||||||
|
STORAGE: "Lagerbehälter",
|
||||||
|
};
|
||||||
@@ -44,4 +44,6 @@ export interface UpdateTaskInput {
|
|||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
assignedUserId?: string;
|
assignedUserId?: string;
|
||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
|
relatedType?: TaskRelatedType;
|
||||||
|
relatedId?: string;
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,8 @@ export interface CreateTreatmentInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTreatmentInput {
|
export interface UpdateTreatmentInput {
|
||||||
|
/** Re-files the treatment under a different colony. */
|
||||||
|
colonyId?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface CreateVarroaMeasurementInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateVarroaMeasurementInput {
|
export interface UpdateVarroaMeasurementInput {
|
||||||
|
/** Re-files the measurement under a different colony. */
|
||||||
|
colonyId?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
method?: VarroaMethod;
|
method?: VarroaMethod;
|
||||||
durationMinutes?: number;
|
durationMinutes?: number;
|
||||||
|
|||||||
Reference in new issue
Block a user