Compare commits

..
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 786d929df9 feat: expose the remaining fields the edit forms were withholding
Backend Image / build-and-push (push) Successful in 2m11s
Frontend Image / build-and-push (push) Successful in 1m53s
The backend accepted every field of these records already; the forms simply
did not offer them, so from the app they were still frozen.

- Inventar: only the condition could be changed. A mistyped inventory number,
  a wrong purchase or maintenance date, a renamed item - all stuck for the life
  of the record. New edit dialog covering every field; the inline condition
  select stays as a shortcut.
- Produkt: the status was shown as a badge but could not be set anywhere, so a
  product could never be discontinued or brought back.
- Aufgabe: the status could only ever move to DONE through the "Erledigt"
  button. IN_PROGRESS and CANCELLED were unreachable, and an accidentally
  completed task could not be reopened.
- Aufgabenvorlage: priority, assignee and the active flag were missing.
  Deactivating was one-way, since only a deactivate endpoint was wired up -
  the active checkbox now reactivates through PATCH.
- Königinnen-Bewertung: notes and the evaluated colony were missing.

Verified by extracting the field set each form submits and comparing it with
the matching update DTO: every form now covers its DTO. The one gap left is
re-linking a document to a different record, which still has no UI.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 23:11:12 +02:00
Nick SzaunigandClaude Opus 5 5a5bd4a138 fix: print the jar size on labels in grams, not millilitres
renderLabelsPdf resolved the jarSize placeholder to "500 ml". Everywhere else
in the app jarSize is a fill weight in grams - the bottling forms label it
"Glasgröße (g)", the batch list and the public Herkunft page print "g", and
BottlingService divides by 1000 to get kilograms. Only the printed label said
millilitres, so every label produced so far declares the wrong net quantity for
a food sold by mass.

The placeholder mapping moved into an exported buildPlaceholderValues() so the
wording can be asserted directly. It could not be before: the render output is
a PDF buffer whose text a test cannot read, which is how the wrong unit shipped
unnoticed. Three tests now cover it, including one that rejects any volume unit
outright.

Labels already printed with "500 ml" have to be reprinted - the code change
does not correct paper.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 02:37:00 +02:00
8 changed files with 486 additions and 14 deletions

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>
<div className="flex items-center gap-2">
<Badge variant={statusTone(equipment.condition)}> <Badge variant={statusTone(equipment.condition)}>
{EQUIPMENT_CONDITION_LABELS[equipment.condition]} {EQUIPMENT_CONDITION_LABELS[equipment.condition]}
</Badge> </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}