Compare commits
3
Commits
e7b157ec35
...
1054a845ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1054a845ef | ||
|
|
c91dc9ebf1 | ||
|
|
e133a677ab |
No files matched your search
+1
-1
@@ -97,7 +97,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
|
||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`, eigene, von der SSO-App-Registrierung getrennte Zugangsdaten `M365_CALENDAR_TENANT_ID`/`_CLIENT_ID`/`_CLIENT_SECRET`, ausschließlich Env-Variablen) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
|
||||
@@ -43,25 +43,22 @@ BACKUP_RETENTION_COUNT=14
|
||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||
# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI
|
||||
# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen.
|
||||
#
|
||||
# Dieselbe App-Registrierung wird auch fuer die M365-Kalender-Synchronisation
|
||||
# (Modul Kalender) wiederverwendet - kein zweites Enterprise-App-Objekt noetig.
|
||||
# Dafuer in der App-Registrierung zusaetzlich die Application-Permission
|
||||
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig); die Tenant-ID wird
|
||||
# aus OIDC_ISSUER_URL abgeleitet. Eintraege (Aufgaben, Durchsichten,
|
||||
# Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) werden
|
||||
# einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-Gruppe
|
||||
# uebertragen. Ohne diese drei Werte bleibt die Synchronisation deaktiviert;
|
||||
# zusaetzlich muessen die Synchronisation selbst UND die jeweilige Gruppen-ID
|
||||
# in der GUI (Verwaltung > Automatisierung > Kalender-Sync) gesetzt werden.
|
||||
OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||
OIDC_CLIENT_ID="change-me-entra-client-id"
|
||||
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
||||
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
||||
|
||||
# M365-Kalender-Synchronisation (Modul Kalender). Eintraege (Aufgaben,
|
||||
# Durchsichten, Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen)
|
||||
# werden einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-
|
||||
# Gruppe uebertragen. Eigene, separate App-Registrierung in Azure/Entra
|
||||
# (NICHT dieselbe wie OIDC_CLIENT_ID oben) mit Application-Permission
|
||||
# "Calendars.ReadWrite" (Admin-Consent noetig), Client-Credentials-Flow.
|
||||
# Ohne alle drei Werte bleibt die Synchronisation deaktiviert. Zusaetzlich
|
||||
# muss die Synchronisation UND die jeweilige Gruppen-ID in der GUI
|
||||
# (Verwaltung > Automatisierung > Kalender-Sync) aktiviert/gesetzt werden -
|
||||
# diese drei Env-Variablen allein reichen nicht.
|
||||
M365_CALENDAR_TENANT_ID="change-me-m365-tenant-id"
|
||||
M365_CALENDAR_CLIENT_ID="change-me-m365-client-id"
|
||||
M365_CALENDAR_CLIENT_SECRET="change-me-m365-client-secret"
|
||||
|
||||
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
|
||||
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "HoneyBatchContainerAllocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"honeyBatchId" TEXT NOT NULL,
|
||||
"storageContainerId" TEXT NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HoneyBatchContainerAllocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HoneyBatchContainerAllocation_storageContainerId_idx" ON "HoneyBatchContainerAllocation"("storageContainerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key" ON "HoneyBatchContainerAllocation"("honeyBatchId", "storageContainerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_storageContainerId_fkey" FOREIGN KEY ("storageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -711,6 +711,7 @@ model HoneyBatch {
|
||||
measurements HoneyMeasurement[]
|
||||
bottlingBatches BottlingBatch[]
|
||||
relocations HoneyBatchRelocation[]
|
||||
containerAllocations HoneyBatchContainerAllocation[]
|
||||
|
||||
@@index([status])
|
||||
@@index([extractionId])
|
||||
@@ -759,6 +760,7 @@ model StorageContainer {
|
||||
honeyBatches HoneyBatch[]
|
||||
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
|
||||
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
|
||||
batchAllocations HoneyBatchContainerAllocation[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
@@ -781,6 +783,27 @@ model HoneyBatchRelocation {
|
||||
@@index([honeyBatchId, relocatedAt])
|
||||
}
|
||||
|
||||
// Aufteilung einer Honigcharge auf mehrere Lagerbehälter (z.B. 125 kg auf 5
|
||||
// Eimer à 25 kg) - additiv zum obigen "primären" storageContainerId/
|
||||
// Umlagern-Fluss auf HoneyBatch, der weiterhin den einfachen Ein-Behälter-
|
||||
// Fall abdeckt; diese Tabelle bildet zusätzlich den Fall ab, dass eine
|
||||
// Charge zu groß für einen einzelnen Behälter ist. @@unique macht das
|
||||
// Zuweisen einer Menge zu einem Behälter ein Upsert (erneutes Zuweisen
|
||||
// überschreibt die Menge, statt einen Duplikat-Eintrag anzulegen).
|
||||
model HoneyBatchContainerAllocation {
|
||||
id String @id @default(cuid())
|
||||
honeyBatchId String
|
||||
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
|
||||
storageContainerId String
|
||||
storageContainer StorageContainer @relation(fields: [storageContainerId], references: [id])
|
||||
amount Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([honeyBatchId, storageContainerId])
|
||||
@@index([storageContainerId])
|
||||
}
|
||||
|
||||
enum StockCategory {
|
||||
HONEY
|
||||
CONSUMABLE
|
||||
|
||||
@@ -17,30 +17,36 @@ describe('GraphClientService', () => {
|
||||
service = new GraphClientService();
|
||||
fetchMock = jest.fn();
|
||||
global.fetch = fetchMock;
|
||||
process.env.M365_CALENDAR_TENANT_ID = 'tenant-1';
|
||||
process.env.M365_CALENDAR_CLIENT_ID = 'client-1';
|
||||
process.env.M365_CALENDAR_CLIENT_SECRET = 'secret-1';
|
||||
process.env.OIDC_ISSUER_URL =
|
||||
'https://login.microsoftonline.com/tenant-1/v2.0';
|
||||
process.env.OIDC_CLIENT_ID = 'client-1';
|
||||
process.env.OIDC_CLIENT_SECRET = 'secret-1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.M365_CALENDAR_TENANT_ID;
|
||||
delete process.env.M365_CALENDAR_CLIENT_ID;
|
||||
delete process.env.M365_CALENDAR_CLIENT_SECRET;
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('returns true when all three env vars are set', () => {
|
||||
it('returns true when all three SSO env vars are set', () => {
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when a required env var is missing', () => {
|
||||
delete process.env.M365_CALENDAR_CLIENT_SECRET;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the issuer URL has no tenant segment', () => {
|
||||
process.env.OIDC_ISSUER_URL = 'https://login.microsoftonline.com/';
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEvent', () => {
|
||||
it('fetches a token, then posts an all-day event and returns the new id', async () => {
|
||||
it('fetches a token from the tenant derived from OIDC_ISSUER_URL, then posts an all-day event', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
|
||||
@@ -54,10 +60,15 @@ describe('GraphClientService', () => {
|
||||
|
||||
expect(result).toBe('graph-event-1');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [tokenUrl] = fetchMock.mock.calls[0] as [string];
|
||||
const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ body: URLSearchParams },
|
||||
];
|
||||
expect(tokenUrl).toBe(
|
||||
'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token',
|
||||
);
|
||||
expect(tokenInit.body.get('client_id')).toBe('client-1');
|
||||
expect(tokenInit.body.get('client_secret')).toBe('secret-1');
|
||||
const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [
|
||||
string,
|
||||
{ headers: Record<string, string>; body: string },
|
||||
|
||||
@@ -27,15 +27,34 @@ function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// Reuses the same Entra app registration as interactive SSO login
|
||||
// (OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET) for the app-only
|
||||
// Graph client-credentials flow, instead of a second, separate app
|
||||
// registration - one enterprise application covers both, as long as it
|
||||
// also has the "Calendars.ReadWrite" Application permission (admin-
|
||||
// consented) alongside its delegated login scopes. OIDC_ISSUER_URL is
|
||||
// "https://login.microsoftonline.com/<tenant-id>/v2.0" - the tenant id is
|
||||
// its first path segment.
|
||||
function tenantIdFromIssuerUrl(issuerUrl: string): string | null {
|
||||
try {
|
||||
const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean);
|
||||
return segments[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GraphClientService {
|
||||
private cachedToken: CachedToken | null = null;
|
||||
|
||||
isConfigured(): boolean {
|
||||
const issuerUrl = process.env.OIDC_ISSUER_URL;
|
||||
return Boolean(
|
||||
process.env.M365_CALENDAR_TENANT_ID &&
|
||||
process.env.M365_CALENDAR_CLIENT_ID &&
|
||||
process.env.M365_CALENDAR_CLIENT_SECRET,
|
||||
issuerUrl &&
|
||||
tenantIdFromIssuerUrl(issuerUrl) &&
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,12 +63,13 @@ export class GraphClientService {
|
||||
return this.cachedToken.accessToken;
|
||||
}
|
||||
|
||||
const tenantId = process.env.M365_CALENDAR_TENANT_ID;
|
||||
const clientId = process.env.M365_CALENDAR_CLIENT_ID;
|
||||
const clientSecret = process.env.M365_CALENDAR_CLIENT_SECRET;
|
||||
const issuerUrl = process.env.OIDC_ISSUER_URL;
|
||||
const clientId = process.env.OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
const tenantId = issuerUrl ? tenantIdFromIssuerUrl(issuerUrl) : null;
|
||||
if (!tenantId || !clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
'M365-Kalender-Synchronisation ist nicht konfiguriert (Env-Variablen fehlen)',
|
||||
'M365-Kalender-Synchronisation ist nicht konfiguriert (SSO-Env-Variablen fehlen)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, IsPositive } from 'class-validator';
|
||||
|
||||
export class SetHoneyBatchContainerAllocationDto {
|
||||
@ApiProperty({ description: 'Menge in kg in diesem Behälter' })
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
amount!: number;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
@@ -16,6 +18,7 @@ import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
|
||||
@ApiTags('honey-batches')
|
||||
@@ -48,6 +51,42 @@ export class HoneyBatchesController {
|
||||
return this.honeyBatchesService.findRelocations(id);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get(':id/containers')
|
||||
listContainerAllocations(@Param('id') id: string) {
|
||||
return this.honeyBatchesService.listContainerAllocations(id);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:relocate')
|
||||
@Put(':id/containers/:containerId')
|
||||
setContainerAllocation(
|
||||
@Param('id') id: string,
|
||||
@Param('containerId') containerId: string,
|
||||
@Body() dto: SetHoneyBatchContainerAllocationDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.honeyBatchesService.setContainerAllocation(
|
||||
id,
|
||||
containerId,
|
||||
dto,
|
||||
actor.id,
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:relocate')
|
||||
@Delete(':id/containers/:containerId')
|
||||
removeContainerAllocation(
|
||||
@Param('id') id: string,
|
||||
@Param('containerId') containerId: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.honeyBatchesService.removeContainerAllocation(
|
||||
id,
|
||||
containerId,
|
||||
actor.id,
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:relocate')
|
||||
@Post(':id/relocate')
|
||||
relocate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -17,6 +17,12 @@ describe('HoneyBatchesService', () => {
|
||||
honeyMeasurement: { create: jest.Mock };
|
||||
storageContainer: { findUnique: jest.Mock };
|
||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||
honeyBatchContainerAllocation: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
upsert: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
@@ -57,6 +63,12 @@ describe('HoneyBatchesService', () => {
|
||||
honeyMeasurement: { create: jest.fn() },
|
||||
storageContainer: { findUnique: jest.fn() },
|
||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||
honeyBatchContainerAllocation: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
@@ -199,4 +211,147 @@ describe('HoneyBatchesService', () => {
|
||||
expect(findArg.where.honeyBatchId).toBe('batch-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setContainerAllocation', () => {
|
||||
it('throws NotFoundException when the container does not exist', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
prisma.storageContainer.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.setContainerAllocation(
|
||||
'batch-1',
|
||||
'missing',
|
||||
{ amount: 25 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('allows splitting the batch amount across multiple containers (125 kg / 5 x 25 kg)', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
amount: 125,
|
||||
});
|
||||
prisma.storageContainer.findUnique.mockResolvedValue({
|
||||
id: 'bucket-5',
|
||||
});
|
||||
prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([
|
||||
{ storageContainerId: 'bucket-1', amount: 25 },
|
||||
{ storageContainerId: 'bucket-2', amount: 25 },
|
||||
{ storageContainerId: 'bucket-3', amount: 25 },
|
||||
{ storageContainerId: 'bucket-4', amount: 25 },
|
||||
]);
|
||||
prisma.honeyBatchContainerAllocation.upsert.mockResolvedValue({
|
||||
id: 'allocation-5',
|
||||
honeyBatchId: 'batch-1',
|
||||
storageContainerId: 'bucket-5',
|
||||
amount: 25,
|
||||
});
|
||||
|
||||
await service.setContainerAllocation(
|
||||
'batch-1',
|
||||
'bucket-5',
|
||||
{ amount: 25 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(prisma.honeyBatchContainerAllocation.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
honeyBatchId_storageContainerId: {
|
||||
honeyBatchId: 'batch-1',
|
||||
storageContainerId: 'bucket-5',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
honeyBatchId: 'batch-1',
|
||||
storageContainerId: 'bucket-5',
|
||||
amount: 25,
|
||||
},
|
||||
update: { amount: 25 },
|
||||
include: { storageContainer: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an allocation that would exceed the batch amount', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
amount: 100,
|
||||
});
|
||||
prisma.storageContainer.findUnique.mockResolvedValue({ id: 'bucket-1' });
|
||||
prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([
|
||||
{ storageContainerId: 'bucket-2', amount: 80 },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.setContainerAllocation(
|
||||
'batch-1',
|
||||
'bucket-1',
|
||||
{ amount: 30 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(
|
||||
prisma.honeyBatchContainerAllocation.upsert,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-assigning the same container overwrites its own amount rather than double-counting it', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
amount: 100,
|
||||
});
|
||||
prisma.storageContainer.findUnique.mockResolvedValue({ id: 'bucket-1' });
|
||||
// findMany excludes the container being set (storageContainerId: { not: ... }),
|
||||
// so its own prior amount never counts against itself here.
|
||||
prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([]);
|
||||
prisma.honeyBatchContainerAllocation.upsert.mockResolvedValue({
|
||||
id: 'allocation-1',
|
||||
honeyBatchId: 'batch-1',
|
||||
storageContainerId: 'bucket-1',
|
||||
amount: 90,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.setContainerAllocation(
|
||||
'batch-1',
|
||||
'bucket-1',
|
||||
{ amount: 90 },
|
||||
'user-1',
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeContainerAllocation', () => {
|
||||
it('throws NotFoundException when no allocation exists for that container', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatchContainerAllocation.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.removeContainerAllocation('batch-1', 'bucket-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes the allocation and records an audit entry', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatchContainerAllocation.findUnique.mockResolvedValue({
|
||||
id: 'allocation-1',
|
||||
honeyBatchId: 'batch-1',
|
||||
storageContainerId: 'bucket-1',
|
||||
amount: 25,
|
||||
});
|
||||
|
||||
await service.removeContainerAllocation('batch-1', 'bucket-1', 'user-1');
|
||||
|
||||
expect(prisma.honeyBatchContainerAllocation.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'allocation-1' },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HoneyBatchContainerAllocation',
|
||||
action: 'DELETE',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
|
||||
const honeyBatchInclude = {
|
||||
measurements: true,
|
||||
@@ -160,6 +161,101 @@ export class HoneyBatchesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// Additive to the single storageContainerId/relocate() flow above: covers
|
||||
// a batch too large for one container (e.g. 125 kg split across 5 x 25 kg
|
||||
// buckets). @@unique on [honeyBatchId, storageContainerId] makes this an
|
||||
// upsert - re-assigning the same container just overwrites its amount.
|
||||
async listContainerAllocations(id: string) {
|
||||
await this.findById(id);
|
||||
return this.prisma.honeyBatchContainerAllocation.findMany({
|
||||
where: { honeyBatchId: id },
|
||||
include: { storageContainer: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async setContainerAllocation(
|
||||
id: string,
|
||||
storageContainerId: string,
|
||||
dto: SetHoneyBatchContainerAllocationDto,
|
||||
actingUserId: string,
|
||||
) {
|
||||
const honeyBatch = await this.findById(id);
|
||||
const container = await this.prisma.storageContainer.findUnique({
|
||||
where: { id: storageContainerId },
|
||||
});
|
||||
if (!container) {
|
||||
throw new NotFoundException('Storage container not found');
|
||||
}
|
||||
|
||||
const otherAllocations =
|
||||
await this.prisma.honeyBatchContainerAllocation.findMany({
|
||||
where: {
|
||||
honeyBatchId: id,
|
||||
storageContainerId: { not: storageContainerId },
|
||||
},
|
||||
});
|
||||
const otherTotal = otherAllocations.reduce((sum, a) => sum + a.amount, 0);
|
||||
if (
|
||||
honeyBatch.amount !== null &&
|
||||
otherTotal + dto.amount > honeyBatch.amount
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Zugewiesene Menge (${otherTotal + dto.amount} kg) übersteigt die Chargenmenge (${honeyBatch.amount} kg)`,
|
||||
);
|
||||
}
|
||||
|
||||
const allocation = await this.prisma.honeyBatchContainerAllocation.upsert({
|
||||
where: {
|
||||
honeyBatchId_storageContainerId: {
|
||||
honeyBatchId: id,
|
||||
storageContainerId,
|
||||
},
|
||||
},
|
||||
create: { honeyBatchId: id, storageContainerId, amount: dto.amount },
|
||||
update: { amount: dto.amount },
|
||||
include: { storageContainer: true },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatchContainerAllocation',
|
||||
entityId: allocation.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
afterData: allocation,
|
||||
});
|
||||
return allocation;
|
||||
}
|
||||
|
||||
async removeContainerAllocation(
|
||||
id: string,
|
||||
storageContainerId: string,
|
||||
actingUserId: string,
|
||||
): Promise<void> {
|
||||
await this.findById(id);
|
||||
const allocation =
|
||||
await this.prisma.honeyBatchContainerAllocation.findUnique({
|
||||
where: {
|
||||
honeyBatchId_storageContainerId: {
|
||||
honeyBatchId: id,
|
||||
storageContainerId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!allocation) {
|
||||
throw new NotFoundException('Zuweisung nicht gefunden');
|
||||
}
|
||||
await this.prisma.honeyBatchContainerAllocation.delete({
|
||||
where: { id: allocation.id },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatchContainerAllocation',
|
||||
entityId: allocation.id,
|
||||
action: 'DELETE',
|
||||
userId: actingUserId,
|
||||
beforeData: allocation,
|
||||
});
|
||||
}
|
||||
|
||||
async findRelocations(id: string) {
|
||||
await this.findById(id);
|
||||
return this.prisma.honeyBatchRelocation.findMany({
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateExtraction } from "@/lib/extractions/api";
|
||||
import { Extraction } from "@/lib/extractions/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState(extraction.date.slice(0, 10));
|
||||
const [amount, setAmount] = useState(extraction.amount?.toString() ?? "");
|
||||
const [room, setRoom] = useState(extraction.room ?? "");
|
||||
const [waterContent, setWaterContent] = useState(extraction.waterContent?.toString() ?? "");
|
||||
const [temperature, setTemperature] = useState(extraction.temperature?.toString() ?? "");
|
||||
const [notes, setNotes] = useState(extraction.notes ?? "");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateExtraction(extraction.id, {
|
||||
date: new Date(date).toISOString(),
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
room: room || undefined,
|
||||
waterContent: waterContent ? Number(waterContent) : undefined,
|
||||
temperature: temperature ? Number(temperature) : undefined,
|
||||
notes: notes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<Pencil className="size-3.5" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schleuderung bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-date">Datum</Label>
|
||||
<Input
|
||||
id="extraction-edit-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-amount">Menge (kg)</Label>
|
||||
<Input
|
||||
id="extraction-edit-amount"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-room">Schleuderraum</Label>
|
||||
<Input
|
||||
id="extraction-edit-room"
|
||||
value={room}
|
||||
onChange={(event) => setRoom(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-water">Wassergehalt (%)</Label>
|
||||
<Input
|
||||
id="extraction-edit-water"
|
||||
type="number"
|
||||
value={waterContent}
|
||||
onChange={(event) => setWaterContent(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-temperature">Temperatur (°C)</Label>
|
||||
<Input
|
||||
id="extraction-edit-temperature"
|
||||
type="number"
|
||||
value={temperature}
|
||||
onChange={(event) => setTemperature(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="extraction-edit-notes">Notizen</Label>
|
||||
<Textarea
|
||||
id="extraction-edit-notes"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchExtraction } from "@/lib/extractions/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ExtractionEditDialog } from "./extraction-edit-dialog";
|
||||
|
||||
export default function ExtractionDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const {
|
||||
data: extraction,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["extractions", params.id],
|
||||
queryFn: () => fetchExtraction(params.id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
||||
}
|
||||
if (isError || !extraction) {
|
||||
return (
|
||||
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
|
||||
Schleuderung konnte nicht geladen werden.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<FlaskConical className="size-4" />
|
||||
</span>
|
||||
<CardTitle>
|
||||
Schleuderung vom {new Date(extraction.date).toLocaleDateString("de-DE")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<ExtractionEditDialog extraction={extraction} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
{extraction.amount !== null ? <p>Menge: {extraction.amount} kg</p> : null}
|
||||
{extraction.room ? <p>Schleuderraum: {extraction.room}</p> : null}
|
||||
{extraction.waterContent !== null ? (
|
||||
<p>Wassergehalt: {extraction.waterContent} %</p>
|
||||
) : null}
|
||||
{extraction.temperature !== null ? (
|
||||
<p>Temperatur: {extraction.temperature} °C</p>
|
||||
) : null}
|
||||
{extraction.notes ? <p>Notizen: {extraction.notes}</p> : null}
|
||||
<div>
|
||||
<p className="font-medium">Beteiligte Ernten</p>
|
||||
<ul className="list-disc pl-5">
|
||||
{extraction.harvests.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<Link href={`/harvests/${entry.harvestId}`} className="hover:underline">
|
||||
{new Date(entry.harvest.date).toLocaleDateString("de-DE")} ·{" "}
|
||||
{entry.harvest.colonies.map((c) => c.colony.code).join(", ")}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
className="w-fit"
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link href="/honey-batches" />}
|
||||
>
|
||||
Zu den Honigchargen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Droplet } from "lucide-react";
|
||||
import { fetchHarvests } from "@/lib/harvests/api";
|
||||
import { createExtraction } from "@/lib/extractions/api";
|
||||
@@ -37,9 +38,10 @@ export default function NewExtractionPage() {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createExtraction,
|
||||
onSuccess: () => {
|
||||
onSuccess: (extraction) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
|
||||
router.push("/colonies");
|
||||
toast.success("Schleuderung gespeichert");
|
||||
router.push(`/extractions/${extraction.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Schleuderung konnte nicht gespeichert werden.");
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchExtractions } from "@/lib/extractions/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
|
||||
export default function ExtractionsPage() {
|
||||
const {
|
||||
data: extractions,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({ queryKey: ["extractions"], queryFn: fetchExtractions });
|
||||
|
||||
const sortedExtractions = [...(extractions ?? [])].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={FlaskConical}
|
||||
title="Schleuderungen"
|
||||
description="Erfasste Schleuderungen aus einer oder mehreren Ernten"
|
||||
actions={
|
||||
<Button nativeButton={false} render={<Link href="/extractions/new" />}>
|
||||
+ Neue Schleuderung
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Schleuderungen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{extractions && sortedExtractions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FlaskConical}
|
||||
title="Noch keine Schleuderungen"
|
||||
description="Erfasse deine erste Schleuderung aus einer oder mehreren Ernten."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sortedExtractions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedExtractions.map((extraction) => (
|
||||
<EntityCard
|
||||
key={extraction.id}
|
||||
href={`/extractions/${extraction.id}`}
|
||||
icon={FlaskConical}
|
||||
title={new Date(extraction.date).toLocaleDateString("de-DE")}
|
||||
subtitle={extraction.room ?? "—"}
|
||||
fields={[
|
||||
{
|
||||
label: "Menge",
|
||||
value: extraction.amount !== null ? `${extraction.amount} kg` : "—",
|
||||
},
|
||||
{
|
||||
label: "Wassergehalt",
|
||||
value: extraction.waterContent !== null ? `${extraction.waterContent} %` : "—",
|
||||
},
|
||||
{ label: "Ernten", value: extraction.harvests.length },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Scissors } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchHarvests } from "@/lib/harvests/api";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
|
||||
export default function HarvestsPage() {
|
||||
const {
|
||||
data: harvests,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({ queryKey: ["harvests"], queryFn: fetchHarvests });
|
||||
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
||||
|
||||
const apiaryNameById = new Map((apiaries ?? []).map((apiary) => [apiary.id, apiary.name]));
|
||||
const sortedHarvests = [...(harvests ?? [])].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Scissors}
|
||||
title="Ernten"
|
||||
description="Erfasste Ernten je Bienenstand"
|
||||
actions={
|
||||
<Button nativeButton={false} render={<Link href="/harvests/new" />}>
|
||||
+ Neue Ernte
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Ernten konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{harvests && sortedHarvests.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Scissors}
|
||||
title="Noch keine Ernten"
|
||||
description="Erfasse deine erste Ernte für einen Bienenstand."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sortedHarvests.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedHarvests.map((harvest) => (
|
||||
<EntityCard
|
||||
key={harvest.id}
|
||||
href={`/harvests/${harvest.id}`}
|
||||
icon={Scissors}
|
||||
title={new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||
subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"}
|
||||
fields={[
|
||||
{ label: "Honigsorte", value: harvest.honeyType ?? "—" },
|
||||
{
|
||||
label: "Nettogewicht",
|
||||
value: harvest.netWeight !== null ? `${harvest.netWeight} kg` : "—",
|
||||
},
|
||||
{ label: "Völker", value: harvest.colonies.length },
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
fetchHoneyBatchContainerAllocations,
|
||||
removeHoneyBatchContainerAllocation,
|
||||
setHoneyBatchContainerAllocation,
|
||||
} from "@/lib/honey-batches/api";
|
||||
import { HoneyBatch } from "@/lib/honey-batches/types";
|
||||
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Boxes } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
// Additive to HoneyBatchStorageCard's single-container "Umlagern" flow above
|
||||
// it on the page - covers a batch too large for one container (e.g. 125 kg
|
||||
// split across 5 x 25 kg buckets), which a single storageContainerId can't
|
||||
// represent.
|
||||
export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [storageContainerId, setStorageContainerId] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: containers } = useQuery({
|
||||
queryKey: ["storage-containers"],
|
||||
queryFn: fetchStorageContainers,
|
||||
});
|
||||
|
||||
const {
|
||||
data: allocations,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["honey-batches", honeyBatch.id, "containers"],
|
||||
queryFn: () => fetchHoneyBatchContainerAllocations(honeyBatch.id),
|
||||
});
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (input: { storageContainerId: string; amount: number }) =>
|
||||
setHoneyBatchContainerAllocation(honeyBatch.id, input.storageContainerId, input.amount),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["honey-batches", honeyBatch.id, "containers"],
|
||||
});
|
||||
setStorageContainerId("");
|
||||
setAmount("");
|
||||
setError(null);
|
||||
},
|
||||
onError: () => {
|
||||
setError("Zuweisung konnte nicht gespeichert werden. Übersteigt die Menge die Charge?");
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (containerId: string) =>
|
||||
removeHoneyBatchContainerAllocation(honeyBatch.id, containerId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["honey-batches", honeyBatch.id, "containers"],
|
||||
});
|
||||
toast.success("Zuweisung entfernt");
|
||||
},
|
||||
onError: () => toast.error("Zuweisung konnte nicht entfernt werden"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
if (!storageContainerId) {
|
||||
setError("Bitte einen Lagerbehälter auswählen.");
|
||||
return;
|
||||
}
|
||||
const parsedAmount = Number(amount);
|
||||
if (!amount || parsedAmount <= 0) {
|
||||
setError("Bitte eine Menge größer als 0 angeben.");
|
||||
return;
|
||||
}
|
||||
assignMutation.mutate({ storageContainerId, amount: parsedAmount });
|
||||
}
|
||||
|
||||
const totalAllocated = (allocations ?? []).reduce((sum, a) => sum + a.amount, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aufteilung auf mehrere Behälter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 text-sm">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Für Chargen, die nicht in einen einzigen Behälter passen (z. B. 125 kg auf 5 Eimer à 25
|
||||
kg). Unabhängig vom Lagerbehälter oben.
|
||||
</p>
|
||||
|
||||
{honeyBatch.amount !== null ? (
|
||||
<p className="text-muted-foreground">
|
||||
Zugewiesen: {totalAllocated} kg von {honeyBatch.amount} kg
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||
{!isLoading && (!allocations || allocations.length === 0) ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="Noch keine Aufteilung"
|
||||
description="Weise dieser Charge unten mehrere Behälter mit jeweiliger Menge zu."
|
||||
/>
|
||||
) : null}
|
||||
{allocations?.map((allocation) => (
|
||||
<div
|
||||
key={allocation.id}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{allocation.storageContainer.code}
|
||||
{allocation.storageContainer.location
|
||||
? ` · ${allocation.storageContainer.location}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{allocation.amount} kg</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={removeMutation.isPending}
|
||||
onClick={() => removeMutation.mutate(allocation.storageContainerId)}
|
||||
aria-label="Zuweisung entfernen"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form className="flex flex-col gap-3 border-t border-border/60 pt-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="allocation-container">Behälter</Label>
|
||||
<Select
|
||||
value={storageContainerId}
|
||||
onValueChange={(value) => setStorageContainerId(value ?? "")}
|
||||
>
|
||||
<SelectTrigger id="allocation-container">
|
||||
<SelectValue placeholder="Lagerbehälter wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers?.map((container) => (
|
||||
<SelectItem key={container.id} value={container.id}>
|
||||
{container.code}
|
||||
{container.location ? ` · ${container.location}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="allocation-amount">Menge (kg)</Label>
|
||||
<Input
|
||||
id="allocation-amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" variant="outline" disabled={assignMutation.isPending}>
|
||||
{assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
|
||||
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -83,6 +84,7 @@ export default function HoneyBatchDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
|
||||
<HoneyBatchContainerAllocationsCard honeyBatch={honeyBatch} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
|
||||
@@ -74,9 +74,9 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Überträgt Kalendereinträge einseitig (ApiCore → M365) in den Kalender einer
|
||||
Microsoft-365-Gruppe. Benötigt eine eigene App-Registrierung in Azure/Entra
|
||||
(Umgebungsvariablen, siehe .env.example) mit Application-Berechtigung
|
||||
„Calendars.ReadWrite“ und Admin-Consent.
|
||||
Microsoft-365-Gruppe. Nutzt dieselbe Azure/Entra-App-Registrierung wie die
|
||||
Microsoft-Anmeldung (SSO) – dafür muss diese zusätzlich die
|
||||
Application-Berechtigung „Calendars.ReadWrite“ mit Admin-Consent erhalten.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -148,8 +148,9 @@ function StatusSection() {
|
||||
<p className="text-muted-foreground">Lädt…</p>
|
||||
) : !status?.configured ? (
|
||||
<p className="text-muted-foreground">
|
||||
Nicht konfiguriert - M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET fehlen als
|
||||
Umgebungsvariable.
|
||||
Nicht konfiguriert - die SSO-Umgebungsvariablen (OIDC_ISSUER_URL/OIDC_CLIENT_ID/
|
||||
OIDC_CLIENT_SECRET) fehlen, oder die App-Registrierung hat noch keine
|
||||
Application-Berechtigung „Calendars.ReadWrite“.
|
||||
</p>
|
||||
) : !canRunNow ? (
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -70,8 +70,8 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Ernte & Lager",
|
||||
items: [
|
||||
{ label: "Ernten", href: "/harvests/new", icon: Scissors },
|
||||
{ label: "Schleuderungen", href: "/extractions/new", icon: FlaskConical },
|
||||
{ label: "Ernten", href: "/harvests", icon: Scissors },
|
||||
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
|
||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
||||
{ label: "Abfüllungen", href: "/bottling", icon: Package },
|
||||
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CreateHoneyBatchInput,
|
||||
CreateHoneyMeasurementInput,
|
||||
HoneyBatch,
|
||||
HoneyBatchContainerAllocation,
|
||||
HoneyBatchRelocation,
|
||||
HoneyBatchStatus,
|
||||
RelocateHoneyBatchInput,
|
||||
@@ -69,3 +70,29 @@ export function relocateHoneyBatch(
|
||||
export function fetchHoneyBatchRelocations(id: string): Promise<HoneyBatchRelocation[]> {
|
||||
return apiFetch<HoneyBatchRelocation[]>(`/honey-batches/${id}/relocations`);
|
||||
}
|
||||
|
||||
export function fetchHoneyBatchContainerAllocations(
|
||||
id: string,
|
||||
): Promise<HoneyBatchContainerAllocation[]> {
|
||||
return apiFetch<HoneyBatchContainerAllocation[]>(`/honey-batches/${id}/containers`);
|
||||
}
|
||||
|
||||
export function setHoneyBatchContainerAllocation(
|
||||
id: string,
|
||||
containerId: string,
|
||||
amount: number,
|
||||
): Promise<HoneyBatchContainerAllocation> {
|
||||
return apiFetch<HoneyBatchContainerAllocation>(
|
||||
`/honey-batches/${id}/containers/${containerId}`,
|
||||
{ method: "PUT", body: JSON.stringify({ amount }) },
|
||||
);
|
||||
}
|
||||
|
||||
export function removeHoneyBatchContainerAllocation(
|
||||
id: string,
|
||||
containerId: string,
|
||||
): Promise<void> {
|
||||
return apiFetch<void>(`/honey-batches/${id}/containers/${containerId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,14 @@ export interface HoneyBatch {
|
||||
measurements: HoneyMeasurement[];
|
||||
}
|
||||
|
||||
export interface HoneyBatchContainerAllocation {
|
||||
id: string;
|
||||
honeyBatchId: string;
|
||||
storageContainerId: string;
|
||||
storageContainer: StorageContainerRef;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface HoneyBatchRelocation {
|
||||
id: string;
|
||||
fromStorageContainerId: string | null;
|
||||
|
||||
@@ -16,12 +16,9 @@ stringData:
|
||||
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
|
||||
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
||||
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI.
|
||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
|
||||
# M365-Kalender-Synchronisation wiederverwendet - dafuer in der
|
||||
# App-Registrierung zusaetzlich die Application-Permission
|
||||
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig).
|
||||
OIDC_CLIENT_ID: "change-me-entra-client-id"
|
||||
OIDC_CLIENT_SECRET: "change-me-entra-client-secret"
|
||||
# Separate App-Registrierung fuer die M365-Kalender-Synchronisation
|
||||
# (Application-Permission "Calendars.ReadWrite", Admin-Consent noetig) -
|
||||
# NICHT dieselbe wie OIDC_CLIENT_ID/-SECRET oben.
|
||||
M365_CALENDAR_TENANT_ID: "change-me-m365-tenant-id"
|
||||
M365_CALENDAR_CLIENT_ID: "change-me-m365-client-id"
|
||||
M365_CALENDAR_CLIENT_SECRET: "change-me-m365-client-secret"
|
||||
Reference in new issue
Block a user