diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index dc7d00f..c6999ee 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -157,6 +157,7 @@ export const PERMISSION_CATALOG = [ [ ['read', 'Ansehen'], ['create', 'Anlegen'], + ['update', 'Bearbeiten'], ['relocate', 'Umlagern'], ['measure', 'Messwerte erfassen'], ], @@ -168,6 +169,7 @@ export const PERMISSION_CATALOG = [ [ ['read', 'Ansehen'], ['create', 'Anlegen'], + ['update', 'Bearbeiten'], ['move', 'Bewegung buchen'], ], 1400, diff --git a/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts b/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts new file mode 100644 index 0000000..ac08495 --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts @@ -0,0 +1,10 @@ +import { PartialType, PickType } from '@nestjs/swagger'; +import { CreateHoneyBatchDto } from './create-honey-batch.dto'; + +export class UpdateHoneyBatchDto extends PartialType( + PickType(CreateHoneyBatchDto, [ + 'honeyType', + 'amount', + 'waterContent', + ] as const), +) {} diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts index f755179..845d8c6 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts @@ -1,10 +1,19 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { HoneyBatchStatus } from '../../generated/prisma'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; 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 { HoneyBatchesService } from './honey-batches.service'; @@ -58,6 +67,16 @@ export class HoneyBatchesController { return this.honeyBatchesService.create(dto, actor.id); } + @RequirePermission('honey-batches:update') + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateHoneyBatchDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.honeyBatchesService.update(id, dto, actor.id); + } + @RequirePermission('honey-batches:measure') @Post(':id/measurements') addMeasurement( diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index 8f1207f..b8091eb 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -4,6 +4,7 @@ import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { HoneyBatchStatus } from '../../generated/prisma'; 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'; @@ -68,6 +69,24 @@ export class HoneyBatchesService { return created; } + async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) { + const before = await this.findById(id); + await this.prisma.honeyBatch.update({ + where: { id }, + data: { ...dto }, + }); + const updated = await this.findById(id); + await this.auditLog.record({ + entityType: 'HoneyBatch', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }); + return updated; + } + async addMeasurement( id: string, dto: CreateHoneyMeasurementDto, diff --git a/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts b/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts new file mode 100644 index 0000000..9445e9f --- /dev/null +++ b/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts @@ -0,0 +1,12 @@ +import { PartialType, PickType } from '@nestjs/swagger'; +import { CreateStockItemDto } from './create-stock-item.dto'; + +export class UpdateStockItemDto extends PartialType( + PickType(CreateStockItemDto, [ + 'name', + 'category', + 'unit', + 'minThreshold', + 'notes', + ] as const), +) {} diff --git a/ApiCore-Backend/src/storage/dto/update-storage-container.dto.ts b/ApiCore-Backend/src/storage/dto/update-storage-container.dto.ts new file mode 100644 index 0000000..831fc0d --- /dev/null +++ b/ApiCore-Backend/src/storage/dto/update-storage-container.dto.ts @@ -0,0 +1,10 @@ +import { PartialType, PickType } from '@nestjs/swagger'; +import { CreateStorageContainerDto } from './create-storage-container.dto'; + +export class UpdateStorageContainerDto extends PartialType( + PickType(CreateStorageContainerDto, [ + 'containerType', + 'capacity', + 'location', + ] as const), +) {} diff --git a/ApiCore-Backend/src/storage/storage.controller.ts b/ApiCore-Backend/src/storage/storage.controller.ts index 85188c3..46367f4 100644 --- a/ApiCore-Backend/src/storage/storage.controller.ts +++ b/ApiCore-Backend/src/storage/storage.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateStorageContainerDto } from './dto/create-storage-container.dto'; +import { UpdateStorageContainerDto } from './dto/update-storage-container.dto'; import { CreateStockItemDto } from './dto/create-stock-item.dto'; +import { UpdateStockItemDto } from './dto/update-stock-item.dto'; import { CreateStockMovementDto } from './dto/create-stock-movement.dto'; import { StorageService } from './storage.service'; @@ -29,6 +31,16 @@ export class StorageController { return this.storageService.createContainer(dto, actor.id); } + @RequirePermission('storage:update') + @Patch('storage-containers/:id') + updateContainer( + @Param('id') id: string, + @Body() dto: UpdateStorageContainerDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.storageService.updateContainer(id, dto, actor.id); + } + @RequirePermission('storage:read') @Get('stock-items') findAllStockItems() { @@ -50,6 +62,16 @@ export class StorageController { return this.storageService.createStockItem(dto, actor.id); } + @RequirePermission('storage:update') + @Patch('stock-items/:id') + updateStockItem( + @Param('id') id: string, + @Body() dto: UpdateStockItemDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.storageService.updateStockItem(id, dto, actor.id); + } + @RequirePermission('storage:move') @Post('stock-items/:id/movements') addMovement( diff --git a/ApiCore-Backend/src/storage/storage.service.ts b/ApiCore-Backend/src/storage/storage.service.ts index e2dacf2..352e4c0 100644 --- a/ApiCore-Backend/src/storage/storage.service.ts +++ b/ApiCore-Backend/src/storage/storage.service.ts @@ -4,7 +4,9 @@ import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { StockItem, StorageContainer } from '../../generated/prisma'; import { CreateStorageContainerDto } from './dto/create-storage-container.dto'; +import { UpdateStorageContainerDto } from './dto/update-storage-container.dto'; import { CreateStockItemDto } from './dto/create-stock-item.dto'; +import { UpdateStockItemDto } from './dto/update-stock-item.dto'; import { CreateStockMovementDto } from './dto/create-stock-movement.dto'; @Injectable() @@ -36,6 +38,32 @@ export class StorageService { return container; } + async updateContainer( + id: string, + dto: UpdateStorageContainerDto, + actingUserId: string, + ): Promise { + const before = await this.prisma.storageContainer.findUnique({ + where: { id }, + }); + if (!before) { + throw new NotFoundException('Storage container not found'); + } + const container = await this.prisma.storageContainer.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'StorageContainer', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: container, + }); + return container; + } + async findAllStockItems(): Promise { return this.prisma.stockItem.findMany({ orderBy: { name: 'asc' } }); } @@ -66,6 +94,27 @@ export class StorageService { return stockItem; } + async updateStockItem( + id: string, + dto: UpdateStockItemDto, + actingUserId: string, + ): Promise { + const before = await this.findStockItemById(id); + const stockItem = await this.prisma.stockItem.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'StockItem', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: stockItem, + }); + return stockItem; + } + async addMovement( id: string, dto: CreateStockMovementDto, diff --git a/ApiCore-Frontend/next.config.ts b/ApiCore-Frontend/next.config.ts index 68a6c64..afea59d 100644 --- a/ApiCore-Frontend/next.config.ts +++ b/ApiCore-Frontend/next.config.ts @@ -2,6 +2,19 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + async headers() { + return [ + { + // Browsers are supposed to bypass HTTP cache when checking for a new + // service worker, but that's not reliably honored everywhere - an + // explicit no-cache header here is the only way to guarantee a sw.js + // fix actually reaches already-open tabs promptly after a deploy, + // instead of possibly sitting cached for hours. + source: "/sw.js", + headers: [{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }], + }, + ]; + }, }; export default nextConfig; diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index 97e9758..6cf2110 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -14,6 +14,7 @@ import { Colony, ColonyType } from "@/lib/colonies/types"; import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; +import { assignQueenColony, fetchQueens } from "@/lib/queens/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -178,6 +179,70 @@ function SplitDialog({ colony }: { colony: Colony }) { ); } +function AssignQueenDialog({ colony }: { colony: Colony }) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [queenId, setQueenId] = useState(""); + const { data: queens } = useQuery({ queryKey: ["queens"], queryFn: () => fetchQueens() }); + const currentQueen = queens?.find((queen) => queen.id === colony.currentQueenId); + + const mutation = useMutation({ + mutationFn: () => assignQueenColony(queenId, { colonyId: colony.id }), + onSuccess: () => { + invalidateColony(queryClient, colony.id); + void queryClient.invalidateQueries({ queryKey: ["queens"] }); + setOpen(false); + }, + }); + + return ( + + + {currentQueen ? `Königin wechseln (${currentQueen.code})` : "Königin zuweisen"} + + } + /> + + + Königin zuweisen + +
{ + event.preventDefault(); + mutation.mutate(); + }} + > +
+ + +
+ + + +
+
+
+ ); +} + function MergeDialog({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); @@ -298,6 +363,7 @@ export function ColonyActions({ colony }: { colony: Colony }) { {colony.status === "ACTIVE" ? ( <> + diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx index a65d592..cd150a8 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx @@ -17,6 +17,7 @@ import { import { statusTone } from "@/lib/status-tone"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { @@ -74,6 +75,8 @@ function ConditionSelect({ export function ColonyEditCard({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); const [colonyType, setColonyType] = useState(colony.colonyType); + const [hiveType, setHiveType] = useState(colony.hiveType ?? ""); + const [originText, setOriginText] = useState(colony.originText ?? ""); const [strength, setStrength] = useState(colony.strength?.toString() ?? ""); const [broodStatus, setBroodStatus] = useState( colony.broodStatus ?? undefined, @@ -90,6 +93,8 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { mutationFn: () => updateColony(colony.id, { colonyType, + hiveType: hiveType || undefined, + originText: originText || undefined, strength: strength ? Number(strength) : undefined, broodStatus, feedStatus, @@ -141,6 +146,22 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { +
+ + setHiveType(event.target.value)} + /> +
+
+ + setOriginText(event.target.value)} + /> +
+
+ + setHiveType(event.target.value)} + /> +
+
+ + setOriginText(event.target.value)} + /> +
{error ? (

{error} diff --git a/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx index 587baa6..6555e59 100644 --- a/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx @@ -5,6 +5,7 @@ import { Wrench } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchEquipment, updateEquipment } from "@/lib/equipment/api"; import { EquipmentCondition } from "@/lib/equipment/types"; +import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels"; import { statusTone } from "@/lib/status-tone"; import { Badge } from "@/components/ui/badge"; import { @@ -16,8 +17,6 @@ import { } from "@/components/ui/select"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -const CONDITIONS: EquipmentCondition[] = ["NEW", "GOOD", "WORN", "DEFECTIVE"]; - export default function EquipmentDetailPage() { const params = useParams<{ id: string }>(); const queryClient = useQueryClient(); @@ -59,7 +58,9 @@ export default function EquipmentDetailPage() { {equipment.name} - {equipment.condition} + + {EQUIPMENT_CONDITION_LABELS[equipment.condition]} +

@@ -85,12 +86,12 @@ export default function EquipmentDetailPage() { } > - + {EQUIPMENT_CONDITION_LABELS[equipment.condition]} - {CONDITIONS.map((condition) => ( + {EQUIPMENT_CONDITIONS.map((condition) => ( - {condition} + {EQUIPMENT_CONDITION_LABELS[condition]} ))} diff --git a/ApiCore-Frontend/src/app/(app)/equipment/page.tsx b/ApiCore-Frontend/src/app/(app)/equipment/page.tsx index a41dafd..f8d78b7 100644 --- a/ApiCore-Frontend/src/app/(app)/equipment/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/equipment/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { Wrench } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { fetchEquipmentList } from "@/lib/equipment/api"; +import { EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels"; import { statusTone } from "@/lib/status-tone"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -56,7 +57,11 @@ export default function EquipmentPage() { gradient="var(--gradient-sage)" title={item.name} subtitle={item.inventoryNumber} - badge={{item.condition}} + badge={ + + {EQUIPMENT_CONDITION_LABELS[item.condition]} + + } fields={[{ label: "Standort", value: item.location ?? "—" }]} /> ))} diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx index 8ce0040..c115159 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx @@ -8,6 +8,7 @@ import { Utensils } from "lucide-react"; import { fetchColonies } from "@/lib/colonies/api"; import { createFeeding } from "@/lib/feedings/api"; import { FeedType } from "@/lib/feedings/types"; +import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -22,14 +23,6 @@ import { import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; -const FEED_TYPE_OPTIONS: FeedType[] = [ - "SUGAR_WATER", - "FEED_SYRUP", - "FEED_PASTE", - "OWN_HONEY", - "OTHER", -]; - function todayIsoDate(): string { return new Date().toISOString().slice(0, 10); } @@ -136,12 +129,12 @@ export default function NewFeedingPage() { onValueChange={(value) => setFeedType((value ?? "SUGAR_WATER") as FeedType)} > - + {FEED_TYPE_LABELS[feedType]} - {FEED_TYPE_OPTIONS.map((option) => ( + {FEED_TYPES.map((option) => ( - {option} + {FEED_TYPE_LABELS[option]} ))} diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.tsx new file mode 100644 index 0000000..7a72095 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { Pencil } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateHoneyBatch } from "@/lib/honey-batches/api"; +import { HoneyBatch } from "@/lib/honey-batches/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [honeyType, setHoneyType] = useState(honeyBatch.honeyType ?? ""); + const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? ""); + const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? ""); + + const mutation = useMutation({ + mutationFn: () => + updateHoneyBatch(honeyBatch.id, { + honeyType: honeyType || undefined, + amount: amount ? Number(amount) : undefined, + waterContent: waterContent ? Number(waterContent) : undefined, + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatch.id] }); + void queryClient.invalidateQueries({ queryKey: ["honey-batches"] }); + setOpen(false); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + mutation.mutate(); + } + + return ( +

+ + + Bearbeiten + + } + /> + + + Honigcharge bearbeiten + +
+
+ + setHoneyType(event.target.value)} + /> +
+
+ + setAmount(event.target.value)} + /> +
+
+ + setWaterContent(event.target.value)} + /> +
+ + + +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx index 06a79dd..2936f7c 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx @@ -5,6 +5,7 @@ import { useParams } from "next/navigation"; import { Droplet, FlaskConical } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api"; +import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels"; import { exportHoneyBatchTraceabilityPdf } from "@/lib/export/api"; import { QrCodeDialog } from "@/components/qr-code-dialog"; import { DocumentUploadCard } from "@/components/document-upload-card"; @@ -15,6 +16,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { AddMeasurementDialog } from "./add-measurement-dialog"; import { ExtractionEditDialog } from "./extraction-edit-dialog"; +import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog"; export default function HoneyBatchDetailPage() { const params = useParams<{ id: string }>(); @@ -56,13 +58,17 @@ export default function HoneyBatchDetailPage() { {honeyBatch.batchNumber}
- {honeyBatch.status} + {HONEY_BATCH_STATUS_LABELS[honeyBatch.status]} +
{honeyBatch.honeyType ?

Sorte: {honeyBatch.honeyType}

: null} {honeyBatch.amount !== null ?

Menge: {honeyBatch.amount} kg

: null} + {honeyBatch.waterContent !== null ? ( +

Wassergehalt: {honeyBatch.waterContent}%

+ ) : null} + } + /> + + + Produkt bearbeiten + +
+
+ + setName(event.target.value)} + /> +
+
+ +