Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
786d929df9 | ||
|
|
5a5bd4a138 |
No files matched your search
@@ -1,5 +1,8 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { LabelTemplatesService } from './label-templates.service';
|
import {
|
||||||
|
LabelTemplatesService,
|
||||||
|
buildPlaceholderValues,
|
||||||
|
} from './label-templates.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { LabelElement } from './label-element.types';
|
import { LabelElement } from './label-element.types';
|
||||||
@@ -415,4 +418,36 @@ describe('LabelTemplatesService', () => {
|
|||||||
expect(countPages(pdf)).toBe(2);
|
expect(countPages(pdf)).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildPlaceholderValues', () => {
|
||||||
|
const bottlingBatch = {
|
||||||
|
jarSize: 500,
|
||||||
|
lotNumber: 'L-001',
|
||||||
|
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||||
|
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||||
|
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||||
|
product: { name: 'Glas 500g' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('prints the jar size in grams, the unit honey is sold by', () => {
|
||||||
|
expect(buildPlaceholderValues(bottlingBatch).jarSize).toBe('500 g');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never labels the jar size as a volume', () => {
|
||||||
|
expect(buildPlaceholderValues(bottlingBatch).jarSize).not.toMatch(
|
||||||
|
/\b(ml|l|cl)\b/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unset honey type and product as empty text, not "null"', () => {
|
||||||
|
const values = buildPlaceholderValues({
|
||||||
|
...bottlingBatch,
|
||||||
|
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
|
||||||
|
product: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.honeyType).toBe('');
|
||||||
|
expect(values.productName).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -104,6 +104,34 @@ function dataUrlToBuffer(dataUrl: string): Buffer | null {
|
|||||||
return matchesFileSignature(buffer, mimeType) ? buffer : null;
|
return matchesFileSignature(buffer, mimeType) ? buffer : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text every placeholder resolves to on a printed label.
|
||||||
|
*
|
||||||
|
* Extracted from the render path so the wording is unit-testable: the render
|
||||||
|
* output is a PDF buffer whose text is not readable by a test, which is how
|
||||||
|
* the jar size once shipped printed as "500 ml". jarSize is a fill weight in
|
||||||
|
* grams everywhere else in the app, and honey is a food sold by mass, so a
|
||||||
|
* label stating millilitres declares the wrong net quantity.
|
||||||
|
*/
|
||||||
|
export function buildPlaceholderValues(bottlingBatch: {
|
||||||
|
jarSize: number;
|
||||||
|
lotNumber: string;
|
||||||
|
date: Date;
|
||||||
|
bestBeforeDate: Date;
|
||||||
|
honeyBatch: { batchNumber: string; honeyType: string | null };
|
||||||
|
product: { name: string } | null;
|
||||||
|
}): Record<PlaceholderKey, string> {
|
||||||
|
return {
|
||||||
|
batchNumber: bottlingBatch.honeyBatch.batchNumber,
|
||||||
|
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
|
||||||
|
lotNumber: bottlingBatch.lotNumber,
|
||||||
|
bottlingDate: formatDate(bottlingBatch.date),
|
||||||
|
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
|
||||||
|
jarSize: `${bottlingBatch.jarSize} g`,
|
||||||
|
productName: bottlingBatch.product?.name ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LabelTemplatesService {
|
export class LabelTemplatesService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -210,15 +238,7 @@ export class LabelTemplatesService {
|
|||||||
throw new NotFoundException('Bottling batch not found');
|
throw new NotFoundException('Bottling batch not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const values: Record<PlaceholderKey, string> = {
|
const values = buildPlaceholderValues(bottlingBatch);
|
||||||
batchNumber: bottlingBatch.honeyBatch.batchNumber,
|
|
||||||
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
|
|
||||||
lotNumber: bottlingBatch.lotNumber,
|
|
||||||
bottlingDate: formatDate(bottlingBatch.date),
|
|
||||||
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
|
|
||||||
jarSize: `${bottlingBatch.jarSize} ml`,
|
|
||||||
productName: bottlingBatch.product?.name ?? '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const elements = elementsOf(template).map((element) =>
|
const elements = elementsOf(template).map((element) =>
|
||||||
element.type === 'text' && element.placeholderKey
|
element.type === 'text' && element.placeholderKey
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { updateEquipment } from "@/lib/equipment/api";
|
||||||
|
import { Equipment, EquipmentCondition } from "@/lib/equipment/types";
|
||||||
|
import {
|
||||||
|
EQUIPMENT_CONDITIONS,
|
||||||
|
EQUIPMENT_CONDITION_LABELS,
|
||||||
|
} from "@/lib/equipment/labels";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects every field of an inventory item. Only the condition used to be
|
||||||
|
* changeable, so a mistyped inventory number or a wrong purchase date stayed
|
||||||
|
* wrong for the life of the record.
|
||||||
|
*/
|
||||||
|
function EquipmentEditForm({
|
||||||
|
equipment,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
equipment: Equipment;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [inventoryNumber, setInventoryNumber] = useState(
|
||||||
|
equipment.inventoryNumber,
|
||||||
|
);
|
||||||
|
const [name, setName] = useState(equipment.name);
|
||||||
|
const [manufacturer, setManufacturer] = useState(equipment.manufacturer ?? "");
|
||||||
|
const [location, setLocation] = useState(equipment.location ?? "");
|
||||||
|
const [purchaseDate, setPurchaseDate] = useState(
|
||||||
|
equipment.purchaseDate?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [maintenanceDate, setMaintenanceDate] = useState(
|
||||||
|
equipment.maintenanceDate?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [condition, setCondition] = useState<EquipmentCondition>(
|
||||||
|
equipment.condition,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateEquipment(equipment.id, {
|
||||||
|
inventoryNumber,
|
||||||
|
name,
|
||||||
|
manufacturer: manufacturer || undefined,
|
||||||
|
location: location || undefined,
|
||||||
|
purchaseDate: purchaseDate
|
||||||
|
? new Date(purchaseDate).toISOString()
|
||||||
|
: undefined,
|
||||||
|
maintenanceDate: maintenanceDate
|
||||||
|
? new Date(maintenanceDate).toISOString()
|
||||||
|
: undefined,
|
||||||
|
condition,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["equipment", equipment.id],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["equipment"] });
|
||||||
|
onSaved();
|
||||||
|
},
|
||||||
|
onError: (mutationError: Error) => {
|
||||||
|
const message =
|
||||||
|
mutationError.message || "Gegenstand konnte nicht gespeichert werden";
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="equipment-edit-inventory-number">Inventarnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="equipment-edit-inventory-number"
|
||||||
|
required
|
||||||
|
value={inventoryNumber}
|
||||||
|
onChange={(event) => setInventoryNumber(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="equipment-edit-name">Bezeichnung</Label>
|
||||||
|
<Input
|
||||||
|
id="equipment-edit-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="equipment-edit-manufacturer">Hersteller</Label>
|
||||||
|
<Input
|
||||||
|
id="equipment-edit-manufacturer"
|
||||||
|
value={manufacturer}
|
||||||
|
onChange={(event) => setManufacturer(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="equipment-edit-location">Standort</Label>
|
||||||
|
<Input
|
||||||
|
id="equipment-edit-location"
|
||||||
|
value={location}
|
||||||
|
onChange={(event) => setLocation(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="equipment-edit-purchase-date">Kaufdatum</Label>
|
||||||
|
<Input
|
||||||
|
id="equipment-edit-purchase-date"
|
||||||
|
type="date"
|
||||||
|
value={purchaseDate}
|
||||||
|
onChange={(event) => setPurchaseDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="equipment-edit-maintenance-date">Wartungsdatum</Label>
|
||||||
|
<Input
|
||||||
|
id="equipment-edit-maintenance-date"
|
||||||
|
type="date"
|
||||||
|
value={maintenanceDate}
|
||||||
|
onChange={(event) => setMaintenanceDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="equipment-edit-condition">Zustand</Label>
|
||||||
|
<Select
|
||||||
|
value={condition}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCondition((value ?? condition) as EquipmentCondition)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="equipment-edit-condition" className="w-full">
|
||||||
|
<SelectValue>{EQUIPMENT_CONDITION_LABELS[condition]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EQUIPMENT_CONDITIONS.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{EQUIPMENT_CONDITION_LABELS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentEditDialog({ equipment }: { equipment: Equipment }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Gegenstand bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{open ? (
|
||||||
|
<EquipmentEditForm
|
||||||
|
equipment={equipment}
|
||||||
|
onSaved={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
||||||
|
import { EquipmentEditDialog } from "./equipment-edit-dialog";
|
||||||
import { EquipmentCondition } from "@/lib/equipment/types";
|
import { EquipmentCondition } from "@/lib/equipment/types";
|
||||||
import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
|
import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
|
||||||
import { statusTone } from "@/lib/status-tone";
|
import { statusTone } from "@/lib/status-tone";
|
||||||
@@ -60,9 +61,12 @@ export default function EquipmentDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
<CardTitle>{equipment.name}</CardTitle>
|
<CardTitle>{equipment.name}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={statusTone(equipment.condition)}>
|
<div className="flex items-center gap-2">
|
||||||
{EQUIPMENT_CONDITION_LABELS[equipment.condition]}
|
<Badge variant={statusTone(equipment.condition)}>
|
||||||
</Badge>
|
{EQUIPMENT_CONDITION_LABELS[equipment.condition]}
|
||||||
|
</Badge>
|
||||||
|
<EquipmentEditDialog equipment={equipment} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3 text-sm">
|
<CardContent className="flex flex-col gap-3 text-sm">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ import { Pencil } from "lucide-react";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateProduct } from "@/lib/products/api";
|
import { updateProduct } from "@/lib/products/api";
|
||||||
import { Product } from "@/lib/products/types";
|
import { Product, ProductStatus } from "@/lib/products/types";
|
||||||
|
import { PRODUCT_STATUS_LABELS } from "@/lib/products/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";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -28,6 +36,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
|||||||
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
|
const [packagingSize, setPackagingSize] = useState(product.packagingSize?.toString() ?? "");
|
||||||
const [weight, setWeight] = useState(product.weight?.toString() ?? "");
|
const [weight, setWeight] = useState(product.weight?.toString() ?? "");
|
||||||
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
|
const [salesUnit, setSalesUnit] = useState(product.salesUnit ?? "");
|
||||||
|
const [status, setStatus] = useState<ProductStatus>(product.status);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -38,6 +47,7 @@ export function EditProductDialog({ product }: { product: Product }) {
|
|||||||
packagingSize: packagingSize ? Number(packagingSize) : undefined,
|
packagingSize: packagingSize ? Number(packagingSize) : undefined,
|
||||||
weight: weight ? Number(weight) : undefined,
|
weight: weight ? Number(weight) : undefined,
|
||||||
salesUnit: salesUnit || undefined,
|
salesUnit: salesUnit || undefined,
|
||||||
|
status,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["products"] });
|
void queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||||
@@ -111,6 +121,28 @@ export function EditProductDialog({ product }: { product: Product }) {
|
|||||||
onChange={(event) => setWeight(event.target.value)}
|
onChange={(event) => setWeight(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="product-edit-status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setStatus((value ?? status) as ProductStatus)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="product-edit-status" className="w-full">
|
||||||
|
<SelectValue>{PRODUCT_STATUS_LABELS[status]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(PRODUCT_STATUS_LABELS) as ProductStatus[]).map(
|
||||||
|
(option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{PRODUCT_STATUS_LABELS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
|
<Label htmlFor="product-edit-sales-unit">Verkaufseinheit</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import { CreateQueenEvaluationInput, QueenEvaluation } from "@/lib/queens/types"
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +41,10 @@ const CRITERIA: { key: keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId
|
|||||||
{ key: "honeyYield", label: "Honigertrag" },
|
{ key: "honeyYield", label: "Honigertrag" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Sentinel for "no colony": the rating may stand on its own, and an empty
|
||||||
|
* select value would render the placeholder rather than a choosable entry. */
|
||||||
|
const NO_COLONY = "__none__";
|
||||||
|
|
||||||
function todayIsoDate(): string {
|
function todayIsoDate(): string {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -58,8 +71,15 @@ function EditEvaluationDialog({
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const [colonyId, setColonyId] = useState(evaluation.colonyId ?? NO_COLONY);
|
||||||
|
const [notes, setNotes] = useState(evaluation.notes ?? "");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: colonies } = useQuery({
|
||||||
|
queryKey: ["colonies"],
|
||||||
|
queryFn: () => fetchColonies(),
|
||||||
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateQueenEvaluation(evaluation.id, {
|
updateQueenEvaluation(evaluation.id, {
|
||||||
@@ -70,6 +90,8 @@ function EditEvaluationDialog({
|
|||||||
swarmingTendency: Number(scores.swarmingTendency),
|
swarmingTendency: Number(scores.swarmingTendency),
|
||||||
broodPattern: Number(scores.broodPattern),
|
broodPattern: Number(scores.broodPattern),
|
||||||
honeyYield: Number(scores.honeyYield),
|
honeyYield: Number(scores.honeyYield),
|
||||||
|
colonyId: colonyId === NO_COLONY ? undefined : colonyId,
|
||||||
|
notes: notes || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
@@ -136,6 +158,38 @@ function EditEvaluationDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`evaluation-edit-colony-${evaluation.id}`}>Volk</Label>
|
||||||
|
<Select
|
||||||
|
value={colonyId}
|
||||||
|
onValueChange={(value) => setColonyId(value ?? NO_COLONY)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={`evaluation-edit-colony-${evaluation.id}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Kein Volk" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_COLONY}>Kein Volk</SelectItem>
|
||||||
|
{(colonies ?? []).map((colony) => (
|
||||||
|
<SelectItem key={colony.id} value={colony.id}>
|
||||||
|
{colony.code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`evaluation-edit-notes-${evaluation.id}`}>
|
||||||
|
Notizen
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`evaluation-edit-notes-${evaluation.id}`}
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { updateTaskTemplate } from "@/lib/task-templates/api";
|
import { updateTaskTemplate } from "@/lib/task-templates/api";
|
||||||
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
|
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
|
||||||
|
import { TaskPriority } from "@/lib/tasks/types";
|
||||||
|
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||||
|
import { fetchUsers } from "@/lib/users/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -25,6 +28,10 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
/** Sentinel for "nobody assigned" - an empty select value renders as the
|
||||||
|
* placeholder instead of a choosable entry. */
|
||||||
|
const NO_ASSIGNEE = "__none__";
|
||||||
|
|
||||||
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
|
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
|
||||||
YEARLY: "Jährlich",
|
YEARLY: "Jährlich",
|
||||||
MONTHLY: "Monatlich",
|
MONTHLY: "Monatlich",
|
||||||
@@ -61,8 +68,23 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
|
|||||||
template.recurrenceDayOfMonth?.toString() ?? "1",
|
template.recurrenceDayOfMonth?.toString() ?? "1",
|
||||||
);
|
);
|
||||||
const [intervalDays, setIntervalDays] = useState(template.intervalDays?.toString() ?? "14");
|
const [intervalDays, setIntervalDays] = useState(template.intervalDays?.toString() ?? "14");
|
||||||
|
const [priority, setPriority] = useState<TaskPriority>(
|
||||||
|
template.priority ?? "NORMAL",
|
||||||
|
);
|
||||||
|
const [assignedUserId, setAssignedUserId] = useState(
|
||||||
|
template.assignedUserId ?? NO_ASSIGNEE,
|
||||||
|
);
|
||||||
|
const [active, setActive] = useState(template.active);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Assigning needs users:read, which not every role has - if the request
|
||||||
|
// fails the field is simply omitted rather than breaking the dialog.
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: () => fetchUsers(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateTaskTemplate(template.id, {
|
updateTaskTemplate(template.id, {
|
||||||
@@ -75,6 +97,10 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
|
|||||||
? Number(recurrenceDayOfMonth)
|
? Number(recurrenceDayOfMonth)
|
||||||
: undefined,
|
: undefined,
|
||||||
intervalDays: recurrenceType === "INTERVAL_DAYS" ? Number(intervalDays) : undefined,
|
intervalDays: recurrenceType === "INTERVAL_DAYS" ? Number(intervalDays) : undefined,
|
||||||
|
priority,
|
||||||
|
assignedUserId:
|
||||||
|
assignedUserId === NO_ASSIGNEE ? undefined : assignedUserId,
|
||||||
|
active,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["task-templates"] });
|
void queryClient.invalidateQueries({ queryKey: ["task-templates"] });
|
||||||
@@ -190,6 +216,60 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="template-edit-priority">Priorität</Label>
|
||||||
|
<Select
|
||||||
|
value={priority}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setPriority((value ?? priority) as TaskPriority)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="template-edit-priority" className="w-full">
|
||||||
|
<SelectValue>{TASK_PRIORITY_LABELS[priority]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TASK_PRIORITIES.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{TASK_PRIORITY_LABELS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{users ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="template-edit-assignee">Zugewiesen an</Label>
|
||||||
|
<Select
|
||||||
|
value={assignedUserId}
|
||||||
|
onValueChange={(value) => setAssignedUserId(value ?? NO_ASSIGNEE)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="template-edit-assignee" className="w-full">
|
||||||
|
<SelectValue placeholder="Niemand" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_ASSIGNEE}>Niemand</SelectItem>
|
||||||
|
{users.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<label
|
||||||
|
className="flex h-9 items-center gap-3 text-sm"
|
||||||
|
htmlFor="template-edit-active"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="template-edit-active"
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4.5"
|
||||||
|
checked={active}
|
||||||
|
onChange={(event) => setActive(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Aktiv – eine deaktivierte Vorlage erzeugt keine Aufgaben mehr
|
||||||
|
</label>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ import { Pencil } from "lucide-react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateTask } from "@/lib/tasks/api";
|
import { updateTask } from "@/lib/tasks/api";
|
||||||
import { Task, TaskPriority, TaskRelatedType } from "@/lib/tasks/types";
|
import {
|
||||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
Task,
|
||||||
|
TaskPriority,
|
||||||
|
TaskRelatedType,
|
||||||
|
TaskStatus,
|
||||||
|
} from "@/lib/tasks/types";
|
||||||
|
import {
|
||||||
|
TASK_PRIORITIES,
|
||||||
|
TASK_PRIORITY_LABELS,
|
||||||
|
TASK_STATUSES,
|
||||||
|
TASK_STATUS_LABELS,
|
||||||
|
} from "@/lib/tasks/labels";
|
||||||
import { fetchUsers } from "@/lib/users/api";
|
import { fetchUsers } from "@/lib/users/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -38,6 +48,7 @@ export function TaskEditDialog({ task }: { task: Task }) {
|
|||||||
const [assignedUserId, setAssignedUserId] = useState(task.assignedUserId ?? "");
|
const [assignedUserId, setAssignedUserId] = useState(task.assignedUserId ?? "");
|
||||||
const [relatedType, setRelatedType] = useState<TaskRelatedType>(task.relatedType);
|
const [relatedType, setRelatedType] = useState<TaskRelatedType>(task.relatedType);
|
||||||
const [relatedId, setRelatedId] = useState(task.relatedId ?? "");
|
const [relatedId, setRelatedId] = useState(task.relatedId ?? "");
|
||||||
|
const [status, setStatus] = useState<TaskStatus>(task.status);
|
||||||
|
|
||||||
// Assigning a task requires users:read, which not every role has - if the
|
// Assigning a task requires users:read, which not every role has - if the
|
||||||
// request fails, the assignee field below is simply omitted rather than
|
// request fails, the assignee field below is simply omitted rather than
|
||||||
@@ -58,6 +69,7 @@ export function TaskEditDialog({ task }: { task: Task }) {
|
|||||||
assignedUserId: assignedUserId || undefined,
|
assignedUserId: assignedUserId || undefined,
|
||||||
relatedType,
|
relatedType,
|
||||||
relatedId: relatedType === "GENERAL" ? undefined : relatedId || undefined,
|
relatedId: relatedType === "GENERAL" ? undefined : relatedId || undefined,
|
||||||
|
status,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
@@ -138,6 +150,30 @@ export function TaskEditDialog({ task }: { task: Task }) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="task-edit-status">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setStatus((value ?? status) as TaskStatus)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="task-edit-status" className="w-full">
|
||||||
|
<SelectValue>{TASK_STATUS_LABELS[status]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TASK_STATUSES.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{TASK_STATUS_LABELS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Hier lässt sich eine versehentlich erledigte oder abgebrochene
|
||||||
|
Aufgabe wieder öffnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<TaskRelatedField
|
<TaskRelatedField
|
||||||
idPrefix="task-edit-related"
|
idPrefix="task-edit-related"
|
||||||
relatedType={relatedType}
|
relatedType={relatedType}
|
||||||
|
|||||||
Reference in new issue
Block a user