fix: Bug fixed für alle Seiten und das Profil und label
Backend Image / build-and-push (push) Successful in 2m44s
Frontend Image / build-and-push (push) Successful in 2m47s

This commit is contained in:
Nick Szaunig committed 2026-08-27 15:15:55 +02:00
1 parent 133edcd5ba
commit 0a52b29c57
52 files changed
+1280 -119

No files matched your search

@@ -157,6 +157,7 @@ export const PERMISSION_CATALOG = [
[ [
['read', 'Ansehen'], ['read', 'Ansehen'],
['create', 'Anlegen'], ['create', 'Anlegen'],
['update', 'Bearbeiten'],
['relocate', 'Umlagern'], ['relocate', 'Umlagern'],
['measure', 'Messwerte erfassen'], ['measure', 'Messwerte erfassen'],
], ],
@@ -168,6 +169,7 @@ export const PERMISSION_CATALOG = [
[ [
['read', 'Ansehen'], ['read', 'Ansehen'],
['create', 'Anlegen'], ['create', 'Anlegen'],
['update', 'Bearbeiten'],
['move', 'Bewegung buchen'], ['move', 'Bewegung buchen'],
], ],
1400, 1400,
@@ -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),
) {}
@@ -1,10 +1,19 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { HoneyBatchStatus } from '../../generated/prisma'; import { HoneyBatchStatus } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; 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 { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto'; import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { HoneyBatchesService } from './honey-batches.service'; import { HoneyBatchesService } from './honey-batches.service';
@@ -58,6 +67,16 @@ export class HoneyBatchesController {
return this.honeyBatchesService.create(dto, actor.id); 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') @RequirePermission('honey-batches:measure')
@Post(':id/measurements') @Post(':id/measurements')
addMeasurement( addMeasurement(
@@ -4,6 +4,7 @@ import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { HoneyBatchStatus } from '../../generated/prisma'; import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; 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 { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto'; import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
@@ -68,6 +69,24 @@ export class HoneyBatchesService {
return created; 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( async addMeasurement(
id: string, id: string,
dto: CreateHoneyMeasurementDto, dto: CreateHoneyMeasurementDto,
@@ -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),
) {}
@@ -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),
) {}
@@ -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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateStorageContainerDto } from './dto/create-storage-container.dto'; 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 { CreateStockItemDto } from './dto/create-stock-item.dto';
import { UpdateStockItemDto } from './dto/update-stock-item.dto';
import { CreateStockMovementDto } from './dto/create-stock-movement.dto'; import { CreateStockMovementDto } from './dto/create-stock-movement.dto';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
@@ -29,6 +31,16 @@ export class StorageController {
return this.storageService.createContainer(dto, actor.id); 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') @RequirePermission('storage:read')
@Get('stock-items') @Get('stock-items')
findAllStockItems() { findAllStockItems() {
@@ -50,6 +62,16 @@ export class StorageController {
return this.storageService.createStockItem(dto, actor.id); 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') @RequirePermission('storage:move')
@Post('stock-items/:id/movements') @Post('stock-items/:id/movements')
addMovement( addMovement(
@@ -4,7 +4,9 @@ import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { StockItem, StorageContainer } from '../../generated/prisma'; import { StockItem, StorageContainer } from '../../generated/prisma';
import { CreateStorageContainerDto } from './dto/create-storage-container.dto'; 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 { CreateStockItemDto } from './dto/create-stock-item.dto';
import { UpdateStockItemDto } from './dto/update-stock-item.dto';
import { CreateStockMovementDto } from './dto/create-stock-movement.dto'; import { CreateStockMovementDto } from './dto/create-stock-movement.dto';
@Injectable() @Injectable()
@@ -36,6 +38,32 @@ export class StorageService {
return container; return container;
} }
async updateContainer(
id: string,
dto: UpdateStorageContainerDto,
actingUserId: string,
): Promise<StorageContainer> {
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<StockItem[]> { async findAllStockItems(): Promise<StockItem[]> {
return this.prisma.stockItem.findMany({ orderBy: { name: 'asc' } }); return this.prisma.stockItem.findMany({ orderBy: { name: 'asc' } });
} }
@@ -66,6 +94,27 @@ export class StorageService {
return stockItem; return stockItem;
} }
async updateStockItem(
id: string,
dto: UpdateStockItemDto,
actingUserId: string,
): Promise<StockItem> {
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( async addMovement(
id: string, id: string,
dto: CreateStockMovementDto, dto: CreateStockMovementDto,
+13
View File
@@ -2,6 +2,19 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", 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; export default nextConfig;
@@ -14,6 +14,7 @@ import { Colony, ColonyType } from "@/lib/colonies/types";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { assignQueenColony, fetchQueens } from "@/lib/queens/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";
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={colony.status !== "ACTIVE"}>
{currentQueen ? `Königin wechseln (${currentQueen.code})` : "Königin zuweisen"}
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Königin zuweisen</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event: FormEvent) => {
event.preventDefault();
mutation.mutate();
}}
>
<div className="flex flex-col gap-2">
<Label htmlFor="assign-queen">Königin</Label>
<Select value={queenId} onValueChange={(value) => setQueenId(value ?? "")}>
<SelectTrigger id="assign-queen">
<SelectValue placeholder="Königin wählen" />
</SelectTrigger>
<SelectContent>
{(queens ?? [])
.filter((queen) => queen.status === "ACTIVE")
.map((queen) => (
<SelectItem key={queen.id} value={queen.id}>
{queen.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="submit" disabled={!queenId || mutation.isPending}>
Zuweisen
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function MergeDialog({ colony }: { colony: Colony }) { function MergeDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -298,6 +363,7 @@ export function ColonyActions({ colony }: { colony: Colony }) {
{colony.status === "ACTIVE" ? ( {colony.status === "ACTIVE" ? (
<> <>
<MoveDialog colony={colony} /> <MoveDialog colony={colony} />
<AssignQueenDialog colony={colony} />
<SplitDialog colony={colony} /> <SplitDialog colony={colony} />
<MergeDialog colony={colony} /> <MergeDialog colony={colony} />
<DissolveButton colony={colony} /> <DissolveButton colony={colony} />
@@ -17,6 +17,7 @@ import {
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
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 { import {
@@ -74,6 +75,8 @@ function ConditionSelect({
export function ColonyEditCard({ colony }: { colony: Colony }) { export function ColonyEditCard({ colony }: { colony: Colony }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType); const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
const [originText, setOriginText] = useState(colony.originText ?? "");
const [strength, setStrength] = useState(colony.strength?.toString() ?? ""); const [strength, setStrength] = useState(colony.strength?.toString() ?? "");
const [broodStatus, setBroodStatus] = useState<ColonyCondition | undefined>( const [broodStatus, setBroodStatus] = useState<ColonyCondition | undefined>(
colony.broodStatus ?? undefined, colony.broodStatus ?? undefined,
@@ -90,6 +93,8 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
mutationFn: () => mutationFn: () =>
updateColony(colony.id, { updateColony(colony.id, {
colonyType, colonyType,
hiveType: hiveType || undefined,
originText: originText || undefined,
strength: strength ? Number(strength) : undefined, strength: strength ? Number(strength) : undefined,
broodStatus, broodStatus,
feedStatus, feedStatus,
@@ -141,6 +146,22 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-hive-type">Beutentyp</Label>
<Input
id="colony-hive-type"
value={hiveType}
onChange={(event) => setHiveType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-origin">Herkunft</Label>
<Input
id="colony-origin"
value={originText}
onChange={(event) => setOriginText(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="colony-strength">Volksstärke</Label> <Label htmlFor="colony-strength">Volksstärke</Label>
<Select value={strength} onValueChange={(value) => setStrength(value ?? "")}> <Select value={strength} onValueChange={(value) => setStrength(value ?? "")}>
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Utensils } from "lucide-react"; import { Utensils } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchColonyFeedings, fetchColonyFeedingsSummary } from "@/lib/feedings/api"; import { fetchColonyFeedings, fetchColonyFeedingsSummary } from "@/lib/feedings/api";
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -52,7 +53,8 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
<div key={feeding.id} className="rounded-md border p-3"> <div key={feeding.id} className="rounded-md border p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-medium"> <p className="font-medium">
{new Date(feeding.date).toLocaleDateString("de-DE")} · {feeding.feedType} {new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "}
{FEED_TYPE_LABELS[feeding.feedType]}
</p> </p>
<FeedingEditDialog feeding={feeding} /> <FeedingEditDialog feeding={feeding} />
</div> </div>
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Bug } from "lucide-react"; import { Bug } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api"; import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -78,7 +79,8 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
<div key={measurement.id} className="rounded-md border p-3"> <div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-medium"> <p className="font-medium">
{new Date(measurement.date).toLocaleDateString("de-DE")} · {measurement.method} {new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "}
{VARROA_METHOD_LABELS[measurement.method]}
</p> </p>
<VarroaEditDialog measurement={measurement} /> <VarroaEditDialog measurement={measurement} />
</div> </div>
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateFeeding } from "@/lib/feedings/api"; import { updateFeeding } from "@/lib/feedings/api";
import { Feeding, FeedType } from "@/lib/feedings/types"; import { Feeding, FeedType } from "@/lib/feedings/types";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
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";
@@ -24,14 +25,6 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
const FEED_TYPE_OPTIONS: FeedType[] = [
"SUGAR_WATER",
"FEED_SYRUP",
"FEED_PASTE",
"OWN_HONEY",
"OTHER",
];
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);
@@ -96,12 +89,12 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)} onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)}
> >
<SelectTrigger id="feeding-edit-type" className="w-full"> <SelectTrigger id="feeding-edit-type" className="w-full">
<SelectValue /> <SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FEED_TYPE_OPTIONS.map((option) => ( {FEED_TYPES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {FEED_TYPE_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateVarroaMeasurement } from "@/lib/varroa/api"; import { updateVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types"; import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
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";
@@ -24,13 +25,6 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
const METHOD_OPTIONS: VarroaMethod[] = [
"NATURAL_MITE_FALL",
"POWDERED_SUGAR",
"WASHING_METHOD",
"DRONE_BROOD_CONTROL",
];
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);
@@ -97,12 +91,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)} onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)}
> >
<SelectTrigger id="varroa-edit-method" className="w-full"> <SelectTrigger id="varroa-edit-method" className="w-full">
<SelectValue /> <SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{METHOD_OPTIONS.map((option) => ( {VARROA_METHODS.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {VARROA_METHOD_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -29,6 +29,8 @@ export default function NewColonyPage() {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [apiaryId, setApiaryId] = useState(""); const [apiaryId, setApiaryId] = useState("");
const [colonyType, setColonyType] = useState<ColonyType>("PRODUCTION_COLONY"); const [colonyType, setColonyType] = useState<ColonyType>("PRODUCTION_COLONY");
const [hiveType, setHiveType] = useState("");
const [originText, setOriginText] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
@@ -49,7 +51,13 @@ export default function NewColonyPage() {
setError("Bitte einen Bienenstand auswählen."); setError("Bitte einen Bienenstand auswählen.");
return; return;
} }
mutation.mutate({ code, apiaryId, colonyType }); mutation.mutate({
code,
apiaryId,
colonyType,
hiveType: hiveType || undefined,
originText: originText || undefined,
});
} }
return ( return (
@@ -104,6 +112,22 @@ export default function NewColonyPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-hive-type">Beutentyp</Label>
<Input
id="colony-hive-type"
value={hiveType}
onChange={(event) => setHiveType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-origin">Herkunft</Label>
<Input
id="colony-origin"
value={originText}
onChange={(event) => setOriginText(event.target.value)}
/>
</div>
{error ? ( {error ? (
<p role="alert" className="text-sm text-destructive"> <p role="alert" className="text-sm text-destructive">
{error} {error}
@@ -5,6 +5,7 @@ import { Wrench } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api"; import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
import { EquipmentCondition } from "@/lib/equipment/types"; import { EquipmentCondition } from "@/lib/equipment/types";
import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@@ -16,8 +17,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const CONDITIONS: EquipmentCondition[] = ["NEW", "GOOD", "WORN", "DEFECTIVE"];
export default function EquipmentDetailPage() { export default function EquipmentDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -59,7 +58,9 @@ export default function EquipmentDetailPage() {
</span> </span>
<CardTitle>{equipment.name}</CardTitle> <CardTitle>{equipment.name}</CardTitle>
</div> </div>
<Badge variant={statusTone(equipment.condition)}>{equipment.condition}</Badge> <Badge variant={statusTone(equipment.condition)}>
{EQUIPMENT_CONDITION_LABELS[equipment.condition]}
</Badge>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm">
<p> <p>
@@ -85,12 +86,12 @@ export default function EquipmentDetailPage() {
} }
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue /> <SelectValue>{EQUIPMENT_CONDITION_LABELS[equipment.condition]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{CONDITIONS.map((condition) => ( {EQUIPMENT_CONDITIONS.map((condition) => (
<SelectItem key={condition} value={condition}> <SelectItem key={condition} value={condition}>
{condition} {EQUIPMENT_CONDITION_LABELS[condition]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Wrench } from "lucide-react"; import { Wrench } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchEquipmentList } from "@/lib/equipment/api"; import { fetchEquipmentList } from "@/lib/equipment/api";
import { EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -56,7 +57,11 @@ export default function EquipmentPage() {
gradient="var(--gradient-sage)" gradient="var(--gradient-sage)"
title={item.name} title={item.name}
subtitle={item.inventoryNumber} subtitle={item.inventoryNumber}
badge={<Badge variant={statusTone(item.condition)}>{item.condition}</Badge>} badge={
<Badge variant={statusTone(item.condition)}>
{EQUIPMENT_CONDITION_LABELS[item.condition]}
</Badge>
}
fields={[{ label: "Standort", value: item.location ?? "—" }]} fields={[{ label: "Standort", value: item.location ?? "—" }]}
/> />
))} ))}
@@ -8,6 +8,7 @@ import { Utensils } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { createFeeding } from "@/lib/feedings/api"; import { createFeeding } from "@/lib/feedings/api";
import { FeedType } from "@/lib/feedings/types"; import { FeedType } from "@/lib/feedings/types";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
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";
@@ -22,14 +23,6 @@ import {
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
const FEED_TYPE_OPTIONS: FeedType[] = [
"SUGAR_WATER",
"FEED_SYRUP",
"FEED_PASTE",
"OWN_HONEY",
"OTHER",
];
function todayIsoDate(): string { function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
@@ -136,12 +129,12 @@ export default function NewFeedingPage() {
onValueChange={(value) => setFeedType((value ?? "SUGAR_WATER") as FeedType)} onValueChange={(value) => setFeedType((value ?? "SUGAR_WATER") as FeedType)}
> >
<SelectTrigger id="feeding-type" className="h-11 w-full"> <SelectTrigger id="feeding-type" className="h-11 w-full">
<SelectValue /> <SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FEED_TYPE_OPTIONS.map((option) => ( {FEED_TYPES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {FEED_TYPE_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -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<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Honigcharge bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
<Input
id="batch-edit-honey-type"
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-amount">Menge (kg)</Label>
<Input
id="batch-edit-amount"
type="number"
inputMode="decimal"
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-water">Wassergehalt (%)</Label>
<Input
id="batch-edit-water"
type="number"
step="0.1"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { Droplet, FlaskConical } from "lucide-react"; import { Droplet, FlaskConical } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api"; 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 { exportHoneyBatchTraceabilityPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AddMeasurementDialog } from "./add-measurement-dialog"; import { AddMeasurementDialog } from "./add-measurement-dialog";
import { ExtractionEditDialog } from "./extraction-edit-dialog"; import { ExtractionEditDialog } from "./extraction-edit-dialog";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
export default function HoneyBatchDetailPage() { export default function HoneyBatchDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
@@ -56,13 +58,17 @@ export default function HoneyBatchDetailPage() {
<CardTitle className="font-mono">{honeyBatch.batchNumber}</CardTitle> <CardTitle className="font-mono">{honeyBatch.batchNumber}</CardTitle>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge>{honeyBatch.status}</Badge> <Badge>{HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}</Badge>
<HoneyBatchEditDialog honeyBatch={honeyBatch} />
<QrCodeDialog entityType="honey-batches" id={honeyBatch.id} /> <QrCodeDialog entityType="honey-batches" id={honeyBatch.id} />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3 text-sm"> <CardContent className="flex flex-col gap-3 text-sm">
{honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null} {honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null}
{honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null} {honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null}
{honeyBatch.waterContent !== null ? (
<p>Wassergehalt: {honeyBatch.waterContent}%</p>
) : null}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -5,6 +5,7 @@ import { Droplet } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { HoneyBatchStatus } from "@/lib/honey-batches/types"; import { HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { exportHoneyBatchesCsv } from "@/lib/export/api"; import { exportHoneyBatchesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -90,7 +91,11 @@ export default function HoneyBatchesPage() {
icon={Droplet} icon={Droplet}
title={<span className="font-mono">{honeyBatch.batchNumber}</span>} title={<span className="font-mono">{honeyBatch.batchNumber}</span>}
subtitle={honeyBatch.honeyType ?? "—"} subtitle={honeyBatch.honeyType ?? "—"}
badge={<Badge variant={statusTone(honeyBatch.status)}>{honeyBatch.status}</Badge>} badge={
<Badge variant={statusTone(honeyBatch.status)}>
{HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}
</Badge>
}
fields={[ fields={[
{ {
label: "Menge", label: "Menge",
@@ -0,0 +1,130 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateProduct } from "@/lib/products/api";
import { Product } from "@/lib/products/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,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function EditProductDialog({ product }: { product: Product }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState(product.name);
const [description, setDescription] = useState(product.description ?? "");
const [sku, setSku] = useState(product.sku ?? "");
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
const [weight, setWeight] = useState(product.weight?.toString() ?? "");
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
const mutation = useMutation({
mutationFn: () =>
updateProduct(product.id, {
name,
description: description || undefined,
sku: sku || undefined,
packagingSize: packagingSize ? Number(packagingSize) : undefined,
weight: weight ? Number(weight) : undefined,
salesUnit: salesUnit || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["products"] });
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Produkt bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-name">Name</Label>
<Input
id="product-edit-name"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-description">Beschreibung</Label>
<Textarea
id="product-edit-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-sku">SKU</Label>
<Input
id="product-edit-sku"
value={sku}
onChange={(event) => setSku(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-packaging-size">Gebindegröße</Label>
<Input
id="product-edit-packaging-size"
type="number"
step="0.01"
value={packagingSize}
onChange={(event) => setPackagingSize(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-weight">Gewicht</Label>
<Input
id="product-edit-weight"
type="number"
step="0.01"
value={weight}
onChange={(event) => setWeight(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
<Input
id="product-edit-sales-unit"
placeholder="z. B. Glas, Flasche"
value={salesUnit}
onChange={(event) => setSalesUnit(event.target.value)}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -5,6 +5,8 @@ import Link from "next/link";
import { ShoppingBag } from "lucide-react"; import { ShoppingBag } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { PRODUCT_STATUS_LABELS } from "@/lib/products/labels";
import { EditProductDialog } from "./edit-product-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -72,13 +74,14 @@ export default function ProductsPage() {
title={product.name} title={product.name}
badge={ badge={
<Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}> <Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}>
{product.status} {PRODUCT_STATUS_LABELS[product.status]}
</Badge> </Badge>
} }
fields={[ fields={[
{ label: "SKU", value: product.sku ?? "—" }, { label: "SKU", value: product.sku ?? "—" },
{ label: "Einheit", value: product.salesUnit ?? "—" }, { label: "Einheit", value: product.salesUnit ?? "—" },
]} ]}
actions={<EditProductDialog product={product} />}
/> />
))} ))}
</div> </div>
@@ -5,6 +5,7 @@ import { Crown } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchQueens, updateQueen } from "@/lib/queens/api"; import { fetchQueens, updateQueen } from "@/lib/queens/api";
import { Queen, QueenStatus } from "@/lib/queens/types"; import { Queen, QueenStatus } from "@/lib/queens/types";
import { QUEEN_STATUSES, QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -25,17 +26,6 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
const QUEEN_STATUSES: QueenStatus[] = [
"ACTIVE",
"RESERVE",
"UNMATED",
"MATED",
"LOST",
"REPLACED",
"SOLD",
"DECEASED",
];
export function QueenEditCard({ queen }: { queen: Queen }) { export function QueenEditCard({ queen }: { queen: Queen }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [breed, setBreed] = useState(queen.breed ?? ""); const [breed, setBreed] = useState(queen.breed ?? "");
@@ -82,7 +72,7 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
</span> </span>
<CardTitle className="font-mono">{queen.code}</CardTitle> <CardTitle className="font-mono">{queen.code}</CardTitle>
</div> </div>
<Badge variant={statusTone(queen.status)}>{queen.status}</Badge> <Badge variant={statusTone(queen.status)}>{QUEEN_STATUS_LABELS[queen.status]}</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
@@ -131,12 +121,12 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
<Label htmlFor="queen-status">Status</Label> <Label htmlFor="queen-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}> <Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
<SelectTrigger id="queen-status"> <SelectTrigger id="queen-status">
<SelectValue /> <SelectValue>{QUEEN_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{QUEEN_STATUSES.map((option) => ( {QUEEN_STATUSES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {QUEEN_STATUS_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
+7 -15
View File
@@ -6,6 +6,7 @@ import { Crown } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchQueens } from "@/lib/queens/api"; import { fetchQueens } from "@/lib/queens/api";
import { QueenStatus } from "@/lib/queens/types"; import { QueenStatus } from "@/lib/queens/types";
import { QUEEN_STATUSES, QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -20,17 +21,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
const STATUS_OPTIONS: QueenStatus[] = [
"ACTIVE",
"RESERVE",
"UNMATED",
"MATED",
"LOST",
"REPLACED",
"SOLD",
"DECEASED",
];
export default function QueensPage() { export default function QueensPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active"); const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [status, setStatus] = useState<QueenStatus | "">(""); const [status, setStatus] = useState<QueenStatus | "">("");
@@ -74,13 +64,15 @@ export default function QueensPage() {
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))} onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue> <SelectValue placeholder="Status">
{status ? QUEEN_STATUS_LABELS[status] : "Alle Status"}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem> <SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => ( {QUEEN_STATUSES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {QUEEN_STATUS_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -118,7 +110,7 @@ export default function QueensPage() {
subtitle={queen.breed ?? "—"} subtitle={queen.breed ?? "—"}
badge={ badge={
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}> <Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
{queen.status} {QUEEN_STATUS_LABELS[queen.status]}
</Badge> </Badge>
} }
fields={[{ label: "Schlupfjahr", value: queen.hatchYear ?? "—" }]} fields={[{ label: "Schlupfjahr", value: queen.hatchYear ?? "—" }]}
@@ -35,9 +35,9 @@ describe("AuditLogPage", () => {
renderWithClient(<AuditLogPage />); renderWithClient(<AuditLogPage />);
expect(await screen.findByText("Apiary")).toBeInTheDocument(); expect(await screen.findByText("Bienenstand")).toBeInTheDocument();
expect(screen.getByText("apiary-1")).toBeInTheDocument(); expect(screen.getByText("apiary-1")).toBeInTheDocument();
expect(screen.getByText("CREATE")).toBeInTheDocument(); expect(screen.getByText("Erstellt")).toBeInTheDocument();
}); });
it("shows an error message when loading the audit log fails", async () => { it("shows an error message when loading the audit log fails", async () => {
@@ -4,6 +4,7 @@ import { useState } from "react";
import { History } from "lucide-react"; import { History } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchAuditLog } from "@/lib/audit-log/api"; import { fetchAuditLog } from "@/lib/audit-log/api";
import { auditActionLabel, auditEntityTypeLabel } from "@/lib/audit-log/labels";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -92,9 +93,9 @@ export default function AuditLogPage() {
{entries.map((entry) => ( {entries.map((entry) => (
<TableRow key={entry.id}> <TableRow key={entry.id}>
<TableCell>{new Date(entry.timestamp).toLocaleString("de-DE")}</TableCell> <TableCell>{new Date(entry.timestamp).toLocaleString("de-DE")}</TableCell>
<TableCell>{entry.entityType}</TableCell> <TableCell>{auditEntityTypeLabel(entry.entityType)}</TableCell>
<TableCell className="font-mono text-xs">{entry.entityId}</TableCell> <TableCell className="font-mono text-xs">{entry.entityId}</TableCell>
<TableCell>{entry.action}</TableCell> <TableCell>{auditActionLabel(entry.action)}</TableCell>
<TableCell>{entry.userId ?? "—"}</TableCell> <TableCell>{entry.userId ?? "—"}</TableCell>
</TableRow> </TableRow>
))} ))}
@@ -0,0 +1,207 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTaskTemplate } from "@/lib/task-templates/api";
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich",
MONTHLY: "Monatlich",
INTERVAL_DAYS: "Alle X Tage",
};
const MONTH_LABELS = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
export function EditTaskTemplateDialog({ template }: { template: TaskTemplate }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(template.title);
const [description, setDescription] = useState(template.description ?? "");
const [recurrenceType, setRecurrenceType] = useState<TaskTemplateRecurrenceType>(
template.recurrenceType,
);
const [recurrenceMonth, setRecurrenceMonth] = useState(
template.recurrenceMonth?.toString() ?? "1",
);
const [recurrenceDayOfMonth, setRecurrenceDayOfMonth] = useState(
template.recurrenceDayOfMonth?.toString() ?? "1",
);
const [intervalDays, setIntervalDays] = useState(template.intervalDays?.toString() ?? "14");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
updateTaskTemplate(template.id, {
title,
description: description || undefined,
recurrenceType,
recurrenceMonth: recurrenceType === "YEARLY" ? Number(recurrenceMonth) : undefined,
recurrenceDayOfMonth:
recurrenceType === "YEARLY" || recurrenceType === "MONTHLY"
? Number(recurrenceDayOfMonth)
: undefined,
intervalDays: recurrenceType === "INTERVAL_DAYS" ? Number(intervalDays) : undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["task-templates"] });
setOpen(false);
},
onError: () => setError("Vorlage konnte nicht gespeichert werden."),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Vorlage bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-title">Titel</Label>
<Input
id="template-edit-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-description">Beschreibung</Label>
<Textarea
id="template-edit-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-recurrence">Wiederholung</Label>
<Select
value={recurrenceType}
onValueChange={(value) =>
setRecurrenceType((value ?? recurrenceType) as TaskTemplateRecurrenceType)
}
>
<SelectTrigger id="template-edit-recurrence" className="w-full">
<SelectValue>{RECURRENCE_LABELS[recurrenceType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(RECURRENCE_LABELS) as TaskTemplateRecurrenceType[]).map(
(option) => (
<SelectItem key={option} value={option}>
{RECURRENCE_LABELS[option]}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
{recurrenceType === "YEARLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-month">Monat</Label>
<Select
value={recurrenceMonth}
onValueChange={(value) => setRecurrenceMonth(value ?? recurrenceMonth)}
>
<SelectTrigger id="template-edit-month" className="w-full">
<SelectValue>{MONTH_LABELS[Number(recurrenceMonth) - 1]}</SelectValue>
</SelectTrigger>
<SelectContent>
{MONTH_LABELS.map((label, index) => (
<SelectItem key={label} value={String(index + 1)}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{recurrenceType === "YEARLY" || recurrenceType === "MONTHLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-day">Tag im Monat</Label>
<Input
id="template-edit-day"
type="number"
min={1}
max={31}
required
value={recurrenceDayOfMonth}
onChange={(event) => setRecurrenceDayOfMonth(event.target.value)}
/>
</div>
) : null}
{recurrenceType === "INTERVAL_DAYS" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-interval">Alle wie viele Tage</Label>
<Input
id="template-edit-interval"
type="number"
min={1}
required
value={intervalDays}
onChange={(event) => setIntervalDays(event.target.value)}
/>
</div>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -13,6 +13,7 @@ import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { CreateTaskTemplateDialog } from "./create-task-template-dialog"; import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
import { EditTaskTemplateDialog } from "./edit-task-template-dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = { const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich", YEARLY: "Jährlich",
@@ -109,6 +110,8 @@ export default function TaskTemplatesPage() {
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) }, { label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
]} ]}
actions={ actions={
<>
<EditTaskTemplateDialog template={template} />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -118,6 +121,7 @@ export default function TaskTemplatesPage() {
> >
Deaktivieren Deaktivieren
</Button> </Button>
</>
} }
/> />
))} ))}
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createStockItem } from "@/lib/storage/api"; import { createStockItem } from "@/lib/storage/api";
import { StockCategory } from "@/lib/storage/types"; import { StockCategory } from "@/lib/storage/types";
import { STOCK_CATEGORIES, STOCK_CATEGORY_LABELS } from "@/lib/storage/labels";
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";
@@ -23,8 +24,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
const CATEGORY_OPTIONS: StockCategory[] = ["HONEY", "CONSUMABLE", "EQUIPMENT_MATERIAL"];
export function CreateStockItemDialog() { export function CreateStockItemDialog() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -84,12 +83,12 @@ export function CreateStockItemDialog() {
onValueChange={(value) => setCategory((value ?? "CONSUMABLE") as StockCategory)} onValueChange={(value) => setCategory((value ?? "CONSUMABLE") as StockCategory)}
> >
<SelectTrigger id="item-category"> <SelectTrigger id="item-category">
<SelectValue /> <SelectValue>{STOCK_CATEGORY_LABELS[category]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{CATEGORY_OPTIONS.map((option) => ( {STOCK_CATEGORIES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {STOCK_CATEGORY_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -0,0 +1,136 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateStockItem } from "@/lib/storage/api";
import { StockCategory, StockItem } from "@/lib/storage/types";
import { STOCK_CATEGORIES, STOCK_CATEGORY_LABELS } from "@/lib/storage/labels";
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,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function EditStockItemDialog({ item }: { item: StockItem }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState(item.name);
const [category, setCategory] = useState<StockCategory>(item.category);
const [unit, setUnit] = useState(item.unit);
const [minThreshold, setMinThreshold] = useState(item.minThreshold?.toString() ?? "");
const [notes, setNotes] = useState(item.notes ?? "");
const mutation = useMutation({
mutationFn: () =>
updateStockItem(item.id, {
name,
category,
unit,
minThreshold: minThreshold ? Number(minThreshold) : undefined,
notes: notes || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["stock-items"] });
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Lagerartikel bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="item-edit-name">Name</Label>
<Input
id="item-edit-name"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-edit-category">Kategorie</Label>
<Select
value={category}
onValueChange={(value) => setCategory((value ?? category) as StockCategory)}
>
<SelectTrigger id="item-edit-category">
<SelectValue>{STOCK_CATEGORY_LABELS[category]}</SelectValue>
</SelectTrigger>
<SelectContent>
{STOCK_CATEGORIES.map((option) => (
<SelectItem key={option} value={option}>
{STOCK_CATEGORY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-edit-unit">Einheit</Label>
<Input
id="item-edit-unit"
required
value={unit}
onChange={(event) => setUnit(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-edit-min-threshold">Mindestbestand</Label>
<Input
id="item-edit-min-threshold"
type="number"
inputMode="decimal"
value={minThreshold}
onChange={(event) => setMinThreshold(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-edit-notes">Notizen</Label>
<Textarea
id="item-edit-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,95 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateStorageContainer } from "@/lib/storage/api";
import { StorageContainer } from "@/lib/storage/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 EditStorageContainerDialog({ container }: { container: StorageContainer }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [containerType, setContainerType] = useState(container.containerType ?? "");
const [capacity, setCapacity] = useState(container.capacity?.toString() ?? "");
const [location, setLocation] = useState(container.location ?? "");
const mutation = useMutation({
mutationFn: () =>
updateStorageContainer(container.id, {
containerType: containerType || undefined,
capacity: capacity ? Number(capacity) : undefined,
location: location || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["storage-containers"] });
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Lagerbehälter bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="container-edit-type">Typ</Label>
<Input
id="container-edit-type"
value={containerType}
onChange={(event) => setContainerType(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="container-edit-capacity">Kapazität (kg)</Label>
<Input
id="container-edit-capacity"
type="number"
inputMode="decimal"
value={capacity}
onChange={(event) => setCapacity(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="container-edit-location">Standort</Label>
<Input
id="container-edit-location"
value={location}
onChange={(event) => setLocation(event.target.value)}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Package, Warehouse } from "lucide-react"; import { Package, Warehouse } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api"; import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api";
import { STOCK_CATEGORY_LABELS, STORAGE_CONTAINER_STATUS_LABELS } from "@/lib/storage/labels";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
@@ -12,6 +13,8 @@ import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { CreateStorageContainerDialog } from "./create-storage-container-dialog"; import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
import { CreateStockItemDialog } from "./create-stock-item-dialog"; import { CreateStockItemDialog } from "./create-stock-item-dialog";
import { EditStorageContainerDialog } from "./edit-storage-container-dialog";
import { EditStockItemDialog } from "./edit-stock-item-dialog";
import { RecordMovementDialog } from "./record-movement-dialog"; import { RecordMovementDialog } from "./record-movement-dialog";
export default function StoragePage() { export default function StoragePage() {
@@ -76,12 +79,15 @@ export default function StoragePage() {
subtitle={container.containerType ?? "—"} subtitle={container.containerType ?? "—"}
badge={ badge={
<Badge variant={container.status === "FILLED" ? "default" : "secondary"}> <Badge variant={container.status === "FILLED" ? "default" : "secondary"}>
{container.status} {STORAGE_CONTAINER_STATUS_LABELS[container.status]}
</Badge> </Badge>
} }
fields={[{ label: "Standort", value: container.location ?? "—" }]} fields={[{ label: "Standort", value: container.location ?? "—" }]}
actions={ actions={
<>
<EditStorageContainerDialog container={container} />
<QrCodeDialog entityType="storage-containers" id={container.id} label="QR" /> <QrCodeDialog entityType="storage-containers" id={container.id} label="QR" />
</>
} }
/> />
))} ))}
@@ -111,14 +117,19 @@ export default function StoragePage() {
icon={Package} icon={Package}
gradient="var(--gradient-sky)" gradient="var(--gradient-sky)"
title={item.name} title={item.name}
subtitle={item.category} subtitle={STOCK_CATEGORY_LABELS[item.category]}
badge={ badge={
item.minThreshold !== null && item.quantity < item.minThreshold ? ( item.minThreshold !== null && item.quantity < item.minThreshold ? (
<Badge variant="destructive">Niedrig</Badge> <Badge variant="destructive">Niedrig</Badge>
) : undefined ) : undefined
} }
fields={[{ label: "Bestand", value: `${item.quantity} ${item.unit}` }]} fields={[{ label: "Bestand", value: `${item.quantity} ${item.unit}` }]}
actions={<RecordMovementDialog stockItemId={item.id} />} actions={
<>
<EditStockItemDialog item={item} />
<RecordMovementDialog stockItemId={item.id} />
</>
}
/> />
))} ))}
</div> </div>
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addStockMovement } from "@/lib/storage/api"; import { addStockMovement } from "@/lib/storage/api";
import { StockMovementType } from "@/lib/storage/types"; import { StockMovementType } from "@/lib/storage/types";
import { STOCK_MOVEMENT_TYPES, STOCK_MOVEMENT_TYPE_LABELS } from "@/lib/storage/labels";
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";
@@ -23,8 +24,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
const TYPE_OPTIONS: StockMovementType[] = ["IN", "OUT", "TRANSFER", "ADJUSTMENT"];
export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) { export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -71,12 +70,12 @@ export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) {
onValueChange={(value) => setType((value ?? "IN") as StockMovementType)} onValueChange={(value) => setType((value ?? "IN") as StockMovementType)}
> >
<SelectTrigger id="movement-type"> <SelectTrigger id="movement-type">
<SelectValue /> <SelectValue>{STOCK_MOVEMENT_TYPE_LABELS[type]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TYPE_OPTIONS.map((option) => ( {STOCK_MOVEMENT_TYPES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {STOCK_MOVEMENT_TYPE_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { ListTodo } from "lucide-react"; import { ListTodo } from "lucide-react";
import { createTask } from "@/lib/tasks/api"; import { createTask } from "@/lib/tasks/api";
import { TaskPriority } from "@/lib/tasks/types"; import { TaskPriority } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
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";
@@ -21,8 +22,6 @@ import {
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
export default function NewTaskPage() { export default function NewTaskPage() {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -82,12 +81,12 @@ export default function NewTaskPage() {
onValueChange={(value) => setPriority((value ?? "NORMAL") as TaskPriority)} onValueChange={(value) => setPriority((value ?? "NORMAL") as TaskPriority)}
> >
<SelectTrigger id="task-priority"> <SelectTrigger id="task-priority">
<SelectValue /> <SelectValue>{TASK_PRIORITY_LABELS[priority]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{PRIORITY_OPTIONS.map((option) => ( {TASK_PRIORITIES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {TASK_PRIORITY_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -6,6 +6,7 @@ import { ListTodo } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { completeTask, fetchTasks } from "@/lib/tasks/api"; import { completeTask, fetchTasks } from "@/lib/tasks/api";
import { TaskPriority, TaskStatus } from "@/lib/tasks/types"; import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS, TASK_STATUS_LABELS } from "@/lib/tasks/labels";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -21,7 +22,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { TaskEditDialog } from "./task-edit-dialog"; import { TaskEditDialog } from "./task-edit-dialog";
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
const HISTORY_STATUSES: TaskStatus[] = ["DONE", "CANCELLED"]; const HISTORY_STATUSES: TaskStatus[] = ["DONE", "CANCELLED"];
export default function TasksPage() { export default function TasksPage() {
@@ -79,13 +79,15 @@ export default function TasksPage() {
} }
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Priorität">{priority || "Alle Prioritäten"}</SelectValue> <SelectValue placeholder="Priorität">
{priority ? TASK_PRIORITY_LABELS[priority] : "Alle Prioritäten"}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">Alle Prioritäten</SelectItem> <SelectItem value="ALL">Alle Prioritäten</SelectItem>
{PRIORITY_OPTIONS.map((option) => ( {TASK_PRIORITIES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {TASK_PRIORITY_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -133,11 +135,11 @@ export default function TasksPage() {
title={task.title} title={task.title}
badge={ badge={
<Badge variant={task.status === "DONE" ? "secondary" : "default"}> <Badge variant={task.status === "DONE" ? "secondary" : "default"}>
{task.status} {TASK_STATUS_LABELS[task.status]}
</Badge> </Badge>
} }
fields={[ fields={[
{ label: "Priorität", value: task.priority }, { label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
{ {
label: "Fällig", label: "Fällig",
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—", value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTask } from "@/lib/tasks/api"; import { updateTask } from "@/lib/tasks/api";
import { Task, TaskPriority } from "@/lib/tasks/types"; import { Task, TaskPriority } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
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";
@@ -24,8 +25,6 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
export function TaskEditDialog({ task }: { task: Task }) { export function TaskEditDialog({ task }: { task: Task }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -84,12 +83,12 @@ export function TaskEditDialog({ task }: { task: Task }) {
onValueChange={(value) => setPriority((value ?? priority) as TaskPriority)} onValueChange={(value) => setPriority((value ?? priority) as TaskPriority)}
> >
<SelectTrigger id="task-edit-priority" className="w-full"> <SelectTrigger id="task-edit-priority" className="w-full">
<SelectValue /> <SelectValue>{TASK_PRIORITY_LABELS[priority]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{PRIORITY_OPTIONS.map((option) => ( {TASK_PRIORITIES.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {TASK_PRIORITY_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -8,6 +8,7 @@ import { Bug } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { createVarroaMeasurement } from "@/lib/varroa/api"; import { createVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMethod } from "@/lib/varroa/types"; import { VarroaMethod } from "@/lib/varroa/types";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
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";
@@ -22,13 +23,6 @@ import {
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
const METHOD_OPTIONS: VarroaMethod[] = [
"NATURAL_MITE_FALL",
"POWDERED_SUGAR",
"WASHING_METHOD",
"DRONE_BROOD_CONTROL",
];
function todayIsoDate(): string { function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
@@ -129,12 +123,12 @@ export default function NewVarroaMeasurementPage() {
onValueChange={(value) => setMethod((value ?? "NATURAL_MITE_FALL") as VarroaMethod)} onValueChange={(value) => setMethod((value ?? "NATURAL_MITE_FALL") as VarroaMethod)}
> >
<SelectTrigger id="varroa-method" className="h-11 w-full"> <SelectTrigger id="varroa-method" className="h-11 w-full">
<SelectValue /> <SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{METHOD_OPTIONS.map((option) => ( {VARROA_METHODS.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {VARROA_METHOD_LABELS[option]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -0,0 +1,53 @@
const AUDIT_ACTION_LABELS: Record<string, string> = {
CREATE: "Erstellt",
UPDATE: "Aktualisiert",
DEACTIVATE: "Deaktiviert",
DELETE: "Gelöscht",
LOGIN: "Angemeldet",
LOGIN_FAILED: "Anmeldung fehlgeschlagen",
LOGOUT: "Abgemeldet",
TOKEN_REFRESHED: "Token erneuert",
MFA_ENABLED: "MFA aktiviert",
MFA_DISABLED: "MFA deaktiviert",
};
export function auditActionLabel(action: string): string {
return AUDIT_ACTION_LABELS[action] ?? action;
}
const AUDIT_ENTITY_TYPE_LABELS: Record<string, string> = {
Apiary: "Bienenstand",
AuthSession: "Sitzung",
AutomationRule: "Automatisierungsregel",
BottlingBatch: "Abfüllung",
Colony: "Volk",
COLONY: "Volk",
Document: "Dokument",
Equipment: "Ausrüstung",
Extraction: "Schleuderung",
Feeding: "Fütterung",
Harvest: "Ernte",
HoneyBatch: "Honigcharge",
HoneyBatchRelocation: "Honigcharge-Umlagerung",
HoneyMeasurement: "Honigmessung",
Import: "Import",
Inspection: "Durchsicht",
Product: "Produkt",
Queen: "Königin",
QueenEvaluation: "Königinnenbewertung",
Role: "Rolle",
StockItem: "Lagerartikel",
StockMovement: "Bestandsbewegung",
StorageContainer: "Lagerbehälter",
SwarmControlCheck: "Schwarmkontrolle",
Task: "Aufgabe",
TaskTemplate: "Aufgabenvorlage",
Treatment: "Behandlung",
User: "Benutzer",
VarroaMeasurement: "Varroamessung",
WebhookSubscription: "Webhook",
};
export function auditEntityTypeLabel(entityType: string): string {
return AUDIT_ENTITY_TYPE_LABELS[entityType] ?? entityType;
}
@@ -0,0 +1,10 @@
import { EquipmentCondition } from "./types";
export const EQUIPMENT_CONDITIONS: EquipmentCondition[] = ["NEW", "GOOD", "WORN", "DEFECTIVE"];
export const EQUIPMENT_CONDITION_LABELS: Record<EquipmentCondition, string> = {
NEW: "Neu",
GOOD: "Gut",
WORN: "Abgenutzt",
DEFECTIVE: "Defekt",
};
@@ -0,0 +1,17 @@
import { FeedType } from "./types";
export const FEED_TYPES: FeedType[] = [
"SUGAR_WATER",
"FEED_SYRUP",
"FEED_PASTE",
"OWN_HONEY",
"OTHER",
];
export const FEED_TYPE_LABELS: Record<FeedType, string> = {
SUGAR_WATER: "Zuckerwasser",
FEED_SYRUP: "Futtersirup",
FEED_PASTE: "Futterteig",
OWN_HONEY: "Eigener Honig",
OTHER: "Sonstiges",
};
@@ -7,6 +7,7 @@ import {
HoneyBatchStatus, HoneyBatchStatus,
RelocateHoneyBatchInput, RelocateHoneyBatchInput,
Traceability, Traceability,
UpdateHoneyBatchInput,
} from "./types"; } from "./types";
export interface HoneyBatchFilter { export interface HoneyBatchFilter {
@@ -35,6 +36,16 @@ export function createHoneyBatch(input: CreateHoneyBatchInput): Promise<HoneyBat
}); });
} }
export function updateHoneyBatch(
id: string,
input: UpdateHoneyBatchInput,
): Promise<HoneyBatch> {
return apiFetch<HoneyBatch>(`/honey-batches/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addHoneyMeasurement( export function addHoneyMeasurement(
id: string, id: string,
input: CreateHoneyMeasurementInput, input: CreateHoneyMeasurementInput,
@@ -0,0 +1,12 @@
import { HoneyBatchStatus } from "./types";
export const HONEY_BATCH_STATUS_LABELS: Record<HoneyBatchStatus, string> = {
RAW: "Roh",
EXTRACTED: "Geschleudert",
CLARIFIED: "Geklärt",
CREAMED: "Cremehonig",
STORED: "Gelagert",
BOTTLED: "Abgefüllt",
SOLD: "Verkauft",
CONSUMED: "Verbraucht",
};
@@ -81,6 +81,12 @@ export interface CreateHoneyBatchInput {
waterContent?: number; waterContent?: number;
} }
export interface UpdateHoneyBatchInput {
honeyType?: string;
amount?: number;
waterContent?: number;
}
export interface CreateHoneyMeasurementInput { export interface CreateHoneyMeasurementInput {
waterContent: number; waterContent: number;
measuredAt: string; measuredAt: string;
@@ -0,0 +1,6 @@
import { ProductStatus } from "./types";
export const PRODUCT_STATUS_LABELS: Record<ProductStatus, string> = {
ACTIVE: "Aktiv",
DISCONTINUED: "Eingestellt",
};
+24
View File
@@ -0,0 +1,24 @@
import { QueenStatus } from "./types";
// Werte und Reihenfolge aus docs/lastenheft.md Abschnitt 4.5 ("Moegliche Status").
export const QUEEN_STATUSES: QueenStatus[] = [
"ACTIVE",
"RESERVE",
"UNMATED",
"MATED",
"LOST",
"REPLACED",
"SOLD",
"DECEASED",
];
export const QUEEN_STATUS_LABELS: Record<QueenStatus, string> = {
ACTIVE: "Aktiv",
RESERVE: "Reserve",
UNMATED: "Unbegattet",
MATED: "Begattet",
LOST: "Verloren",
REPLACED: "Ersetzt",
SOLD: "Verkauft",
DECEASED: "Verstorben",
};
+22
View File
@@ -5,6 +5,8 @@ import {
CreateStorageContainerInput, CreateStorageContainerInput,
StockItem, StockItem,
StorageContainer, StorageContainer,
UpdateStockItemInput,
UpdateStorageContainerInput,
} from "./types"; } from "./types";
export function fetchStorageContainers(): Promise<StorageContainer[]> { export function fetchStorageContainers(): Promise<StorageContainer[]> {
@@ -20,6 +22,16 @@ export function createStorageContainer(
}); });
} }
export function updateStorageContainer(
id: string,
input: UpdateStorageContainerInput,
): Promise<StorageContainer> {
return apiFetch<StorageContainer>(`/storage-containers/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function fetchStockItems(): Promise<StockItem[]> { export function fetchStockItems(): Promise<StockItem[]> {
return apiFetch<StockItem[]>("/stock-items"); return apiFetch<StockItem[]>("/stock-items");
} }
@@ -31,6 +43,16 @@ export function createStockItem(input: CreateStockItemInput): Promise<StockItem>
}); });
} }
export function updateStockItem(
id: string,
input: UpdateStockItemInput,
): Promise<StockItem> {
return apiFetch<StockItem>(`/stock-items/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function addStockMovement( export function addStockMovement(
id: string, id: string,
input: CreateStockMovementInput, input: CreateStockMovementInput,
@@ -0,0 +1,25 @@
import { StockCategory, StockMovementType, StorageContainerStatus } from "./types";
export const STORAGE_CONTAINER_STATUS_LABELS: Record<StorageContainerStatus, string> = {
EMPTY: "Leer",
FILLED: "Gefüllt",
CLEANING: "Reinigung",
OUT_OF_SERVICE: "Außer Betrieb",
};
export const STOCK_CATEGORIES: StockCategory[] = ["HONEY", "CONSUMABLE", "EQUIPMENT_MATERIAL"];
export const STOCK_CATEGORY_LABELS: Record<StockCategory, string> = {
HONEY: "Honig",
CONSUMABLE: "Verbrauchsmaterial",
EQUIPMENT_MATERIAL: "Ausrüstungsmaterial",
};
export const STOCK_MOVEMENT_TYPES: StockMovementType[] = ["IN", "OUT", "TRANSFER", "ADJUSTMENT"];
export const STOCK_MOVEMENT_TYPE_LABELS: Record<StockMovementType, string> = {
IN: "Eingang",
OUT: "Ausgang",
TRANSFER: "Umlagerung",
ADJUSTMENT: "Korrektur",
};
+14
View File
@@ -41,6 +41,12 @@ export interface CreateStorageContainerInput {
location?: string; location?: string;
} }
export interface UpdateStorageContainerInput {
containerType?: string;
capacity?: number;
location?: string;
}
export interface CreateStockItemInput { export interface CreateStockItemInput {
name: string; name: string;
category: StockCategory; category: StockCategory;
@@ -50,6 +56,14 @@ export interface CreateStockItemInput {
notes?: string; notes?: string;
} }
export interface UpdateStockItemInput {
name?: string;
category?: StockCategory;
unit?: string;
minThreshold?: number;
notes?: string;
}
export interface CreateStockMovementInput { export interface CreateStockMovementInput {
type: StockMovementType; type: StockMovementType;
quantity: number; quantity: number;
+19
View File
@@ -0,0 +1,19 @@
import { TaskPriority, TaskStatus } from "./types";
export const TASK_STATUSES: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
OPEN: "Offen",
IN_PROGRESS: "In Bearbeitung",
DONE: "Erledigt",
CANCELLED: "Abgebrochen",
};
export const TASK_PRIORITIES: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
export const TASK_PRIORITY_LABELS: Record<TaskPriority, string> = {
LOW: "Niedrig",
NORMAL: "Normal",
HIGH: "Hoch",
URGENT: "Dringend",
};
+15
View File
@@ -0,0 +1,15 @@
import { VarroaMethod } from "./types";
export const VARROA_METHODS: VarroaMethod[] = [
"NATURAL_MITE_FALL",
"POWDERED_SUGAR",
"WASHING_METHOD",
"DRONE_BROOD_CONTROL",
];
export const VARROA_METHOD_LABELS: Record<VarroaMethod, string> = {
NATURAL_MITE_FALL: "Natürlicher Milbenfall",
POWDERED_SUGAR: "Puderzuckermethode",
WASHING_METHOD: "Waschmethode",
DRONE_BROOD_CONTROL: "Drohnenbrutentnahme",
};