Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export

# Conflicts:
#	ARCHITECTURE.md
This commit is contained in:
Nick Szaunig committed 2026-10-13 05:58:30 +02:00
commit 9c332d4716
310 files changed
+12177 -3746

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx next typegen
# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und
# Unit-Tests gruen sind.
RUN npm run lint && npx tsc --noEmit && npm test
RUN npm run build
FROM node:22-alpine AS runtime
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiaryEditCard } from "./apiary-edit-card";
import { updateApiary } from "@/lib/apiaries/api";
import type { Apiary } from "@/lib/apiaries/types";
vi.mock("@/lib/apiaries/api", () => ({
updateApiary: vi.fn(),
deactivateApiary: vi.fn(),
reactivateApiary: vi.fn(),
}));
const apiary: Apiary = {
id: "a-1",
name: "Obstwiese",
description: "Hinter der Scheune",
location: "Altenmünster",
latitude: 48.47,
longitude: 10.6,
address: "Hauptstr. 1",
status: "ACTIVE",
notes: "Schlüssel beim Nachbarn",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deactivatedAt: null,
};
describe("ApiaryEditCard", () => {
beforeEach(() => {
vi.mocked(updateApiary).mockReset();
vi.mocked(updateApiary).mockResolvedValue(apiary);
});
it("sends emptied optional fields as null so they are cleared, not kept", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ApiaryEditCard apiary={apiary} />
</QueryClientProvider>,
);
for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen"]) {
await userEvent.clear(screen.getByLabelText(new RegExp(`^${label}`)));
}
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateApiary).toHaveBeenCalledWith("a-1", {
name: "Obstwiese",
description: null,
location: null,
latitude: null,
longitude: null,
address: null,
notes: null,
});
});
});
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deactivateApiary, reactivateApiary, updateApiary } from "@/lib/apiaries/api";
import { Apiary } from "@/lib/apiaries/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -29,12 +30,12 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
mutationFn: () =>
updateApiary(apiary.id, {
name,
description: description || undefined,
location: location || undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
address: address || undefined,
notes: notes || undefined,
description: emptyToNull(description),
location: emptyToNull(location),
latitude: emptyToNull(latitude, Number),
longitude: emptyToNull(longitude, Number),
address: emptyToNull(address),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
@@ -7,6 +7,7 @@ import { TrachtprognoseRating } from "@/lib/weather/types";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ErrorState, LoadingState } from "@/components/query-state";
import { todayLocalIsoDate } from "@/lib/dates";
const RATING_LABELS: Record<TrachtprognoseRating, string> = {
GUT: "Gute Tracht",
@@ -74,7 +75,7 @@ export function WeatherCard({
const trachtprognose = trachtQuery.data;
const weather = weatherQuery.data;
const today = new Date().toISOString().slice(0, 10);
const today = todayLocalIsoDate();
const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7);
const todaySuitability = forecastDays[0]?.inspectionSuitable ?? null;
@@ -1,4 +1,5 @@
import type { ApiaryInput } from "@/lib/apiaries/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ApiaryFormValues {
@@ -35,18 +36,13 @@ export function formatCoordinate(value: number): string {
return value.toFixed(6);
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toApiaryInput(values: ApiaryFormValues): ApiaryInput {
return {
name: values.name.trim(),
location: textOrUndefined(values.location),
latitude: numberOrUndefined(values.latitude),
longitude: numberOrUndefined(values.longitude),
address: textOrUndefined(values.address),
notes: textOrUndefined(values.notes),
location: emptyToNull(values.location),
latitude: emptyToNull(values.latitude, Number),
longitude: emptyToNull(values.longitude, Number),
address: emptyToNull(values.address),
notes: emptyToNull(values.notes),
};
}
@@ -63,11 +63,11 @@ describe("NewApiaryPage", () => {
expect(vi.mocked(createApiary).mock.calls[0][0]).toEqual({
name: "Hausgarten",
location: undefined,
latitude: undefined,
longitude: undefined,
location: null,
latitude: null,
longitude: null,
address: "Dorfstraße 1",
notes: undefined,
notes: null,
});
expect(push).toHaveBeenCalledWith("/apiaries/apiary-3");
});
@@ -11,13 +11,8 @@ import { fetchProducts } from "@/lib/products/api";
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 { RecordSelect } from "@/components/form/record-select";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import {
Dialog,
DialogContent,
@@ -25,13 +20,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
/**
* Sentinel for "no product": the select needs a selectable item to clear the
* assignment again, and an empty string would be read as "nothing chosen yet"
* and render the placeholder instead.
*/
const NO_PRODUCT = "__none__";
import { toApiDate } from "@/lib/dates";
export function BottlingEditDialog({
bottlingBatch,
@@ -83,9 +72,8 @@ function BottlingEditForm({
const [jarSize, setJarSize] = useState(String(bottlingBatch.jarSize));
const [jarCount, setJarCount] = useState(String(bottlingBatch.jarCount));
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
const [productId, setProductId] = useState(
bottlingBatch.productId ?? NO_PRODUCT,
);
// "" = "Kein Produkt" - choosing it clears the assignment again.
const [productId, setProductId] = useState(bottlingBatch.productId ?? "");
const [error, setError] = useState<string | null>(null);
const { data: honeyBatches } = useQuery({
@@ -127,12 +115,12 @@ function BottlingEditForm({
mutationFn: () =>
updateBottlingBatch(bottlingBatch.id, {
honeyBatchId,
date: new Date(date).toISOString(),
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
date: toApiDate(date),
bestBeforeDate: toApiDate(bestBeforeDate),
jarSize: Number(jarSize),
jarCount: Number(jarCount),
lotNumber,
productId: productId === NO_PRODUCT ? null : productId,
productId: emptyToNull(productId),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
@@ -157,21 +145,14 @@ function BottlingEditForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-honey-batch">Honigcharge</Label>
<Select
<RecordSelect
id="bottling-edit-honey-batch"
value={honeyBatchId}
onValueChange={(value) => setHoneyBatchId(value ?? honeyBatchId)}
>
<SelectTrigger id="bottling-edit-honey-batch">
<SelectValue placeholder="Charge wählen" />
</SelectTrigger>
<SelectContent>
{honeyBatchOptions.map((honeyBatch) => (
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
{honeyBatch.batchNumber}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setHoneyBatchId}
items={honeyBatchOptions}
getLabel={(honeyBatch) => honeyBatch.batchNumber}
placeholder="Charge wählen"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-date">Datum</Label>
@@ -221,22 +202,14 @@ function BottlingEditForm({
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-product">Produkt</Label>
<Select
<RecordSelect
id="bottling-edit-product"
value={productId}
onValueChange={(value) => setProductId(value ?? NO_PRODUCT)}
>
<SelectTrigger id="bottling-edit-product">
<SelectValue placeholder="Produkt wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PRODUCT}>Kein Produkt</SelectItem>
{productOptions.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setProductId}
items={productOptions}
getLabel={(product) => product.name}
noneLabel="Kein Produkt"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
@@ -9,13 +9,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) {
const [templateId, setTemplateId] = useState("");
@@ -57,21 +51,14 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }
</p>
) : (
<>
<Select
<RecordSelect
ariaLabel="Etikettenvorlage"
value={effectiveTemplateId}
onValueChange={(value) => setTemplateId(value ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Vorlage wählen">{selectedTemplate?.name}</SelectValue>
</SelectTrigger>
<SelectContent>
{activeTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setTemplateId}
items={activeTemplates}
getLabel={(template) => template.name}
placeholder="Vorlage wählen"
/>
<div className="flex flex-col gap-2">
<Label htmlFor="print-start-position">
Startposition auf dem Bogen (1 = oben links)
@@ -1,4 +1,6 @@
import type { createBottlingBatch } from "@/lib/bottling/api";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface BottlingFormValues {
@@ -11,15 +13,11 @@ export interface BottlingFormValues {
lotNumber: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialBottlingValues(honeyBatchId: string): BottlingFormValues {
return {
honeyBatchId,
productId: "",
date: todayIsoDate(),
date: todayLocalIsoDate(),
jarSize: "500",
jarCount: "",
bestBeforeDate: "",
@@ -32,11 +30,12 @@ export function toBottlingInput(
): Parameters<typeof createBottlingBatch>[0] {
return {
honeyBatchId: values.honeyBatchId,
productId: values.productId || undefined,
date: new Date(values.date).toISOString(),
productId: emptyToNull(values.productId),
date: toApiDate(values.date),
jarSize: Number(values.jarSize),
jarCount: Number(values.jarCount),
bestBeforeDate: new Date(values.bestBeforeDate).toISOString(),
lotNumber: values.lotNumber.trim() ? values.lotNumber.trim() : undefined,
bestBeforeDate: toApiDate(values.bestBeforeDate),
// Empty: the server generates the number (automation format).
lotNumber: emptyToNull(values.lotNumber),
};
}
@@ -66,7 +66,7 @@ describe("NewBottlingBatchPage", () => {
jarCount: 24,
bestBeforeDate: new Date("2028-07-01").toISOString(),
});
expect(input.productId).toBeUndefined();
expect(input.productId).toBeNull();
expect(push).toHaveBeenCalledWith("/bottling/b-1");
});
@@ -12,13 +12,8 @@ import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { honeyBatchOptionLabel } from "@/lib/honey-batches/labels";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -164,6 +159,7 @@ export default function NewBottlingBatchPage() {
>
<FormSection step={1} title="Welcher Honig, wann?">
<Field
id="bottling-honey-batch"
label="Honigcharge"
required
error={fieldErrors.honeyBatchId}
@@ -173,37 +169,20 @@ export default function NewBottlingBatchPage() {
: undefined
}
>
{({ describedBy, invalid }) => (
<Select
{({ id, describedBy, invalid }) => (
<RecordSelect
id={id}
dataField="honeyBatchId"
describedBy={describedBy}
invalid={invalid}
value={values.honeyBatchId}
onValueChange={(value) => set("honeyBatchId", value ?? "")}
>
<SelectTrigger
id="bottling-honey-batch"
data-field="honeyBatchId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
honeyBatchesLoading
? "Chargen werden geladen…"
: "Charge wählen"
}
/>
</SelectTrigger>
<SelectContent>
{honeyBatches?.map((honeyBatch) => (
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
{honeyBatch.batchNumber}
{honeyBatch.amount !== null
? ` · ${honeyBatch.amount} kg`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(value) => set("honeyBatchId", value)}
items={honeyBatches}
getLabel={honeyBatchOptionLabel}
placeholder={
honeyBatchesLoading ? "Chargen werden geladen…" : "Charge wählen"
}
/>
)}
</Field>
<Field label="Datum" required error={fieldErrors.date}>
@@ -301,23 +280,14 @@ export default function NewBottlingBatchPage() {
>
<Field label="Produkt">
{({ id }) => (
<Select
<RecordSelect
id={id}
value={values.productId}
onValueChange={(value) => set("productId", value ?? "")}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Produkt wählen" />
</SelectTrigger>
<SelectContent>
{products
?.filter((product) => product.kind !== "MANUFACTURED")
.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(value) => set("productId", value)}
items={products?.filter((product) => product.kind !== "MANUFACTURED")}
getLabel={(product) => product.name}
placeholder="Produkt wählen"
/>
)}
</Field>
<Field
@@ -16,7 +16,10 @@ export function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed inset-x-0 bottom-0 z-30 flex border-t bg-background sm:hidden">
// Height and padding include the iOS home-indicator inset, so the labels
// stay tappable above it; --bottom-nav-height is shared with the content
// padding, StickySubmit and the quick-add button.
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-(--bottom-nav-height) border-t bg-background pb-(--safe-area-bottom) sm:hidden">
{ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
@@ -25,7 +28,7 @@ export function BottomNav() {
key={item.href}
href={item.href}
className={cn(
"flex flex-1 flex-col items-center gap-1 py-2 text-xs",
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
@@ -22,14 +22,9 @@ import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { cn } from "@/lib/utils";
import { toLocalIsoDate } from "@/lib/dates";
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -50,13 +45,6 @@ function eventHref(event: CalendarEvent): string | null {
return null;
}
/** Local calendar day "2026-10-01" (not UTC - late evening entries stay on their day). */
function toDateKey(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
@@ -129,8 +117,8 @@ function DayEvents({
export default function CalendarPage() {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const [apiaryId, setApiaryId] = useState("");
const [selectedKey, setSelectedKey] = useState(() => toDateKey(new Date()));
const todayKey = toDateKey(new Date());
const [selectedKey, setSelectedKey] = useState(() => toLocalIsoDate(new Date()));
const todayKey = toLocalIsoDate(new Date());
const monthStart = startOfMonth(month);
const gridStart = startOfGrid(monthStart);
@@ -157,7 +145,7 @@ export default function CalendarPage() {
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of eventsQuery.data ?? []) {
const key = toDateKey(new Date(event.date));
const key = toLocalIsoDate(new Date(event.date));
const bucket = map.get(key) ?? [];
bucket.push(event);
map.set(key, bucket);
@@ -167,9 +155,9 @@ export default function CalendarPage() {
const daysOfMonthWithEvents = gridDays.filter(
(day) =>
day.getMonth() === month.getMonth() && (eventsByDay.get(toDateKey(day))?.length ?? 0) > 0,
day.getMonth() === month.getMonth() && (eventsByDay.get(toLocalIsoDate(day))?.length ?? 0) > 0,
);
const selectedDay = gridDays.find((day) => toDateKey(day) === selectedKey);
const selectedDay = gridDays.find((day) => toLocalIsoDate(day) === selectedKey);
function goToMonth(offset: number) {
setMonth(new Date(month.getFullYear(), month.getMonth() + offset, 1));
@@ -214,26 +202,15 @@ export default function CalendarPage() {
<Button variant="ghost" size="touch" onClick={goToToday}>
Heute
</Button>
<Select
value={apiaryId || "ALL"}
onValueChange={(value) => setApiaryId(value === "ALL" ? "" : (value ?? ""))}
>
<SelectTrigger className="w-full sm:ml-auto sm:w-52" aria-label="Bienenstand">
<SelectValue placeholder="Alle Bienenstände">
{apiaryId
? (apiaries?.find((apiary) => apiary.id === apiaryId)?.name ?? apiaryId)
: "Alle Bienenstände"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Bienenstände</SelectItem>
{apiaries?.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
<RecordSelect
ariaLabel="Bienenstand"
className="sm:ml-auto sm:w-52"
value={apiaryId}
onChange={setApiaryId}
items={apiaries}
getLabel={(apiary) => apiary.name}
noneLabel="Alle Bienenstände"
/>
</div>
{eventsQuery.isLoading ? <LoadingState what="Einträge" /> : null}
@@ -254,10 +231,10 @@ export default function CalendarPage() {
) : (
daysOfMonthWithEvents.map((day) => (
<DayEvents
key={toDateKey(day)}
key={toLocalIsoDate(day)}
date={day}
events={eventsByDay.get(toDateKey(day)) ?? []}
isToday={toDateKey(day) === todayKey}
events={eventsByDay.get(toLocalIsoDate(day)) ?? []}
isToday={toLocalIsoDate(day) === todayKey}
/>
))
)}
@@ -275,7 +252,7 @@ export default function CalendarPage() {
</div>
))}
{gridDays.map((day) => {
const key = toDateKey(day);
const key = toLocalIsoDate(day);
const dayEvents = eventsByDay.get(key) ?? [];
const isCurrentMonth = day.getMonth() === month.getMonth();
const isToday = key === todayKey;
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyActions } from "./colony-actions";
import { fetchColonies, mergeColony } from "@/lib/colonies/api";
import type { Colony } from "@/lib/colonies/types";
import type { Colony, ColonyStatus } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({
fetchColonies: vi.fn(),
@@ -21,7 +21,7 @@ vi.mock("@/lib/queens/api", () => ({
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function colony(id: string, code: string): Colony {
function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colony {
return {
id,
code,
@@ -29,7 +29,7 @@ function colony(id: string, code: string): Colony {
hiveType: null,
originText: null,
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
status,
strength: null,
broodStatus: null,
feedStatus: null,
@@ -45,13 +45,13 @@ function colony(id: string, code: string): Colony {
const target = colony("colony-1", "V-01");
const other = colony("colony-2", "V-02");
function renderActions() {
function renderActions(subject: Colony = target) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ColonyActions colony={target} />
<ColonyActions colony={subject} />
</QueryClientProvider>,
);
}
@@ -102,3 +102,41 @@ describe("ColonyActions – merge", () => {
expect(mergeColony).not.toHaveBeenCalled();
});
});
describe("ColonyActions – colonies that need attention", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([
target,
other,
colony("colony-3", "V-03", "QUEENLESS"),
colony("colony-4", "V-04", "DISSOLVED"),
]);
});
it("lets a weak colony be moved, split, merged and dissolved instead of only reactivated", () => {
renderActions(colony("colony-5", "V-05", "WEAK"));
for (const name of ["Umsetzen", "Ableger bilden", "Vereinigen", "Auflösen"]) {
expect(screen.getByRole("button", { name })).toBeEnabled();
}
expect(screen.queryByRole("button", { name: "Reaktivieren" })).not.toBeInTheDocument();
});
it("only offers reactivation for a colony that has ended", () => {
renderActions(colony("colony-4", "V-04", "DISSOLVED"));
expect(screen.getByRole("button", { name: "Reaktivieren" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Umsetzen" })).not.toBeInTheDocument();
});
it("offers a queenless colony for merging - and no ended one", async () => {
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Vereinigen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("combobox"));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["V-02", "V-03 (Weisellos)"]);
});
});
@@ -12,6 +12,7 @@ import {
} from "@/lib/colonies/api";
import { Colony, ColonyType } from "@/lib/colonies/types";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { isLivingColony } from "@/lib/colonies/status";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { assignQueenColony, fetchQueens } from "@/lib/queens/api";
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { LivingColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -29,13 +31,8 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
function invalidateColony(
queryClient: ReturnType<typeof useQueryClient>,
@@ -95,7 +92,7 @@ function MoveDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={colony.status !== "ACTIVE"}>
<Button variant="outline" disabled={!isLivingColony(colony)}>
Umsetzen
</Button>
}
@@ -118,18 +115,14 @@ function MoveDialog({ colony }: { colony: Colony }) {
>
<Field label="Neuer Bienenstand" required>
{({ id }) => (
<Select value={apiaryId} onValueChange={(value) => setApiaryId(value ?? "")}>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger>
<SelectContent>
{targets.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
<RecordSelect
id={id}
value={apiaryId}
onChange={setApiaryId}
items={apiaries ? targets : undefined}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
)}
</Field>
<ActionFooter error={error}>
@@ -177,7 +170,7 @@ function SplitDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={colony.status !== "ACTIVE"}>
<Button variant="outline" disabled={!isLivingColony(colony)}>
Ableger bilden
</Button>
}
@@ -208,21 +201,13 @@ function SplitDialog({ colony }: { colony: Colony }) {
</Field>
<Field label="Volkstyp" required>
{({ id }) => (
<Select
<EnumSelect
id={id}
value={colonyType}
onValueChange={(value) => setColonyType(value as ColonyType)}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{COLONY_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setColonyType}
values={COLONY_TYPES}
labels={COLONY_TYPE_LABELS}
/>
)}
</Field>
<ActionFooter error={error}>
@@ -259,7 +244,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={colony.status !== "ACTIVE"}>
<Button variant="outline" disabled={!isLivingColony(colony)}>
{currentQueen ? `Königin wechseln (${currentQueen.code})` : "Königin zuweisen"}
</Button>
}
@@ -278,20 +263,14 @@ function AssignQueenDialog({ colony }: { colony: Colony }) {
>
<Field label="Königin" required>
{({ id }) => (
<Select value={queenId} onValueChange={(value) => setQueenId(value ?? "")}>
<SelectTrigger id={id} className="h-11 w-full">
<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>
<RecordSelect
id={id}
value={queenId}
onChange={setQueenId}
items={queens?.filter((queen) => queen.status === "ACTIVE")}
getLabel={(queen) => queen.code}
placeholder="Königin wählen"
/>
)}
</Field>
<ActionFooter error={error}>
@@ -315,10 +294,10 @@ function MergeDialog({ colony }: { colony: Colony }) {
const [mergedColonyId, setMergedColonyId] = useState("");
const [error, setError] = useState<string | null>(null);
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const candidates = (colonies ?? []).filter(
(candidate) => candidate.id !== colony.id && candidate.status === "ACTIVE",
const merged = (colonies ?? []).find(
(candidate) =>
candidate.id === mergedColonyId && candidate.id !== colony.id && isLivingColony(candidate),
);
const merged = candidates.find((candidate) => candidate.id === mergedColonyId);
const mutation = useMutation({
mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }),
@@ -338,7 +317,7 @@ function MergeDialog({ colony }: { colony: Colony }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={colony.status !== "ACTIVE"}>
<Button variant="outline" disabled={!isLivingColony(colony)}>
Vereinigen
</Button>
}
@@ -351,28 +330,17 @@ function MergeDialog({ colony }: { colony: Colony }) {
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<Field label="Welches Volk kommt dazu?" required>
{({ id }) => (
<Select
value={mergedColonyId}
onValueChange={(value) => {
setMergedColonyId(value ?? "");
setError(null);
}}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
{candidates.map((candidate) => (
<SelectItem key={candidate.id} value={candidate.id}>
{candidate.code}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<LivingColonySelectField
triggerId={`merge-into-${colony.id}`}
label="Welches Volk kommt dazu?"
excludeId={colony.id}
emptyHint="Es gibt kein anderes Volk, das dazukommen könnte."
value={mergedColonyId}
onChange={(colonyId) => {
setMergedColonyId(colonyId);
setError(null);
}}
/>
<ActionFooter error={error}>
<ConfirmDialog
triggerLabel={mutation.isPending ? "Wird vereinigt…" : "Vereinigen"}
@@ -412,7 +380,7 @@ function DissolveButton({ colony }: { colony: Colony }) {
return (
<ConfirmDialog
triggerLabel="Auflösen"
triggerDisabled={colony.status !== "ACTIVE" || mutation.isPending}
triggerDisabled={!isLivingColony(colony) || mutation.isPending}
title="Volk auflösen?"
description={`„${colony.code}“ wird als aufgelöst markiert und aus den aktiven Völkern entfernt. Die Historie bleibt erhalten und das Volk kann jederzeit wieder reaktiviert werden.`}
confirmLabel="Volk auflösen"
@@ -449,7 +417,7 @@ function ReactivateButton({ colony }: { colony: Colony }) {
export function ColonyActions({ colony }: { colony: Colony }) {
return (
<div className="flex flex-wrap gap-2">
{colony.status === "ACTIVE" ? (
{isLivingColony(colony) ? (
<>
<MoveDialog colony={colony} />
<AssignQueenDialog colony={colony} />
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyEditCard } from "./colony-edit-card";
import { updateColony } from "@/lib/colonies/api";
import type { Colony } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() }));
vi.mock("@/lib/apiaries/api", () => ({
fetchApiaries: vi.fn().mockResolvedValue([{ id: "apiary-1", name: "Obstwiese" }]),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const colony: Colony = {
id: "colony-1",
code: "V-01",
apiaryId: "apiary-1",
hiveType: "Zander",
originText: "Schwarm 2025",
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: 7,
broodStatus: "GOOD",
feedStatus: "AVERAGE",
healthStatus: "CRITICAL",
notes: "sanft",
currentQueenId: null,
createdAt: "",
updatedAt: "",
deactivatedAt: null,
};
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ColonyEditCard colony={colony} />
</QueryClientProvider>,
);
}
async function choose(trigger: RegExp, option: string) {
await userEvent.click(screen.getByRole("combobox", { name: trigger }));
await userEvent.click(await screen.findByRole("option", { name: option }));
}
describe("ColonyEditCard", () => {
beforeEach(() => {
vi.mocked(updateColony).mockReset();
vi.mocked(updateColony).mockResolvedValue(colony);
});
it("clears emptied fields by sending null instead of keeping the old value", async () => {
renderCard();
await userEvent.clear(screen.getByLabelText("Beutentyp"));
await userEvent.clear(screen.getByLabelText("Herkunft"));
await userEvent.clear(screen.getByLabelText("Notizen"));
await choose(/Volksstärke/, "Nicht erfasst");
await choose(/Gesundheitsstatus/, "Nicht erfasst");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateColony).mock.calls[0];
expect(input).toMatchObject({
hiveType: null,
originText: null,
notes: null,
strength: null,
healthStatus: null,
broodStatus: "GOOD",
feedStatus: "AVERAGE",
});
});
it("names the apiary in its select, not by its internal id", async () => {
renderCard();
const apiary = screen.getByRole("combobox", { name: "Bienenstand" });
await waitFor(() => expect(apiary).toHaveTextContent("Obstwiese"));
expect(apiary).not.toHaveTextContent("apiary-1");
});
it("names every choice by its label and sends the chosen values", async () => {
renderCard();
expect(screen.getByRole("combobox", { name: "Brutstatus" })).toHaveTextContent("Gut");
expect(screen.getByRole("combobox", { name: "Volksstärke" })).toHaveTextContent("7");
await choose(/^Status/, "Weisellos");
await choose(/^Volktyp/, "Ableger");
await choose(/Brutstatus/, "Kritisch");
await choose(/Volksstärke/, "9");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateColony).mock.calls[0];
expect(input).toMatchObject({
status: "QUEENLESS",
colonyType: "NUCLEUS_COLONY",
broodStatus: "CRITICAL",
strength: 9,
});
});
it("shows each condition as its coloured badge, in the field and in the list", async () => {
renderCard();
const brood = screen.getByRole("combobox", { name: "Brutstatus" });
expect(brood.querySelector('[data-slot="badge"]')?.className).toContain("condition-good");
await userEvent.click(brood);
const critical = await screen.findByRole("option", { name: "Kritisch" });
expect(critical.querySelector('[data-slot="badge"]')?.className).toContain(
"condition-critical",
);
expect(
(await screen.findByRole("option", { name: "Nicht erfasst" })).querySelector(
'[data-slot="badge"]',
),
).toBeNull();
});
it("offers only the living statuses for free choice", async () => {
renderCard();
await userEvent.click(screen.getByRole("combobox", { name: /^Status/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Aktiv",
"Schwach",
"Auffällig",
"Weisellos",
]);
});
it("keeps untouched values and trims text", async () => {
renderCard();
await userEvent.type(screen.getByLabelText("Beutentyp"), " ");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateColony).mock.calls[0];
expect(input).toMatchObject({
hiveType: "Zander",
originText: "Schwarm 2025",
strength: 7,
healthStatus: "CRITICAL",
notes: "sanft",
});
});
});
@@ -12,23 +12,19 @@ import {
COLONY_CONDITION_TONE_CLASS,
COLONY_EDITABLE_STATUSES,
COLONY_STATUS_LABELS,
COLONY_STRENGTH_OPTIONS,
COLONY_STRENGTH_LABELS,
COLONY_TYPES,
COLONY_TYPE_LABELS,
} from "@/lib/colonies/labels";
import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { EnumSelect } from "@/components/form/enum-select";
import {
Card,
CardContent,
@@ -36,6 +32,9 @@ import {
CardTitle,
} from "@/components/ui/card";
/** The choice that clears an optional rating - sent as null. */
const NOT_RECORDED_LABEL = "Nicht erfasst";
function ConditionSelect({
id,
label,
@@ -44,32 +43,25 @@ function ConditionSelect({
}: {
id: string;
label: string;
value: ColonyCondition | undefined;
onChange: (value: ColonyCondition) => void;
value: ColonyCondition | null;
onChange: (value: ColonyCondition | null) => void;
}) {
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Select value={value} onValueChange={(next) => onChange(next as ColonyCondition)}>
<SelectTrigger id={id}>
<SelectValue placeholder="Nicht erfasst">
{value ? (
<Badge className={COLONY_CONDITION_TONE_CLASS[value]}>
{COLONY_CONDITION_LABELS[value]}
</Badge>
) : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_CONDITIONS.map((condition) => (
<SelectItem key={condition} value={condition}>
<Badge className={COLONY_CONDITION_TONE_CLASS[condition]}>
{COLONY_CONDITION_LABELS[condition]}
</Badge>
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
id={id}
labels={COLONY_CONDITION_LABELS}
values={COLONY_CONDITIONS}
noneLabel={NOT_RECORDED_LABEL}
value={value ?? ""}
onChange={(next) => onChange(next || null)}
renderOption={(condition) => (
<Badge className={COLONY_CONDITION_TONE_CLASS[condition]}>
{COLONY_CONDITION_LABELS[condition]}
</Badge>
)}
/>
</div>
);
}
@@ -83,14 +75,10 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
const [originText, setOriginText] = useState(colony.originText ?? "");
const [strength, setStrength] = useState(colony.strength?.toString() ?? "");
const [broodStatus, setBroodStatus] = useState<ColonyCondition | undefined>(
colony.broodStatus ?? undefined,
);
const [feedStatus, setFeedStatus] = useState<ColonyCondition | undefined>(
colony.feedStatus ?? undefined,
);
const [healthStatus, setHealthStatus] = useState<ColonyCondition | undefined>(
colony.healthStatus ?? undefined,
const [broodStatus, setBroodStatus] = useState<ColonyCondition | null>(colony.broodStatus);
const [feedStatus, setFeedStatus] = useState<ColonyCondition | null>(colony.feedStatus);
const [healthStatus, setHealthStatus] = useState<ColonyCondition | null>(
colony.healthStatus,
);
const [notes, setNotes] = useState(colony.notes ?? "");
@@ -106,13 +94,14 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
code,
status,
colonyType,
hiveType: hiveType || undefined,
originText: originText || undefined,
strength: strength ? Number(strength) : undefined,
// Emptied fields go out as null - omitted, the old value would stay.
hiveType: emptyToNull(hiveType),
originText: emptyToNull(originText),
strength: emptyToNull(strength, Number),
broodStatus,
feedStatus,
healthStatus,
notes: notes || undefined,
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
@@ -138,21 +127,15 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-apiary">Bienenstand</Label>
<Select
<RecordSelect
id="colony-apiary"
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
>
<SelectTrigger id="colony-apiary">
<SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger>
<SelectContent>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
// A colony always stands somewhere: there is no "none" to pick.
onChange={(value) => setApiaryId(value || apiaryId)}
items={apiaries}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
<p className="text-xs text-muted-foreground">
Eine Korrektur wird wie ein Umsetzen in der Standort-Historie
festgehalten.
@@ -169,36 +152,23 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as ColonyStatus)}>
<SelectTrigger id="colony-status">
<SelectValue>{COLONY_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_EDITABLE_STATUSES.map((option) => (
<SelectItem key={option} value={option}>
{COLONY_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
id="colony-status"
labels={COLONY_STATUS_LABELS}
values={COLONY_EDITABLE_STATUSES}
value={status}
onChange={setStatus}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-type">Volktyp</Label>
<Select
<EnumSelect
id="colony-type"
labels={COLONY_TYPE_LABELS}
values={COLONY_TYPES}
value={colonyType}
onValueChange={(value) => setColonyType(value as ColonyType)}
>
<SelectTrigger id="colony-type">
<SelectValue>{COLONY_TYPE_LABELS[colonyType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{COLONY_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setColonyType}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-hive-type">Beutentyp</Label>
@@ -218,18 +188,13 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="colony-strength">Volksstärke</Label>
<Select value={strength} onValueChange={(value) => setStrength(value ?? "")}>
<SelectTrigger id="colony-strength">
<SelectValue placeholder="Nicht erfasst" />
</SelectTrigger>
<SelectContent>
{COLONY_STRENGTH_OPTIONS.map((option) => (
<SelectItem key={option} value={option.toString()}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
id="colony-strength"
labels={COLONY_STRENGTH_LABELS}
noneLabel={NOT_RECORDED_LABEL}
value={strength}
onChange={setStrength}
/>
</div>
<ConditionSelect
id="colony-brood"
@@ -5,16 +5,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateFeeding } from "@/lib/feedings/api";
import { Feeding, FeedType } from "@/lib/feedings/types";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
@@ -31,6 +26,7 @@ import {
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
interface FieldErrors {
date?: string;
@@ -56,13 +52,14 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
mutationFn: () =>
updateFeeding(feeding.id, {
colonyId,
date: new Date(date).toISOString(),
date: toApiDate(date),
feedType,
product: product || undefined,
amount: amount ? Number(amount) : undefined,
product: emptyToNull(product),
// Required (checked in handleSubmit) - never cleared.
amount: Number(amount),
unit,
batchNumber: batchNumber || undefined,
notes: notes || undefined,
batchNumber: emptyToNull(batchNumber),
notes: emptyToNull(notes),
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
@@ -129,21 +126,13 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) {
<FormSection step={2} title="Was und wie viel?">
<Field label="Futterart" required>
{({ id }) => (
<Select
<EnumSelect
id={id}
value={feedType}
onValueChange={(value) => setFeedType((value ?? feedType) as FeedType)}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{FEED_TYPE_LABELS[feedType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{FEED_TYPES.map((option) => (
<SelectItem key={option} value={option}>
{FEED_TYPE_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setFeedType}
values={FEED_TYPES}
labels={FEED_TYPE_LABELS}
/>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InspectionEditDialog } from "./inspection-edit-dialog";
@@ -25,7 +25,7 @@ const inspection: Inspection = {
cappedBroodPresent: true,
eggsPresent: true,
queenSeen: false,
queenCellsPresent: false,
queenCellsPresent: null,
swarmMood: null,
gentleness: 2,
combBehavior: null,
@@ -63,11 +63,8 @@ describe("InspectionEditDialog", () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog();
// Order: Brutbild, Sanftmut, Wabensitz, Schwarmstimmung, Futter.
const gentleness = screen.getAllByRole("radiogroup")[1];
await userEvent.click(
gentleness.querySelector('[aria-label="5 von 5"]') as HTMLElement,
);
const gentleness = screen.getByRole("radiogroup", { name: /^Sanftmut/ });
await userEvent.click(within(gentleness).getByRole("radio", { name: "5 von 5" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [id, input] = vi.mocked(updateInspection).mock.calls[0];
@@ -80,6 +77,62 @@ describe("InspectionEditDialog", () => {
});
});
it("shows stored answers and keeps 'not checked' as not checked", async () => {
await openDialog();
const checked = (group: string) =>
within(screen.getByRole("radiogroup", { name: group }))
.getAllByRole("radio")
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent;
expect(checked("Stifte vorhanden")).toBe("Ja");
expect(checked("Königin gesehen")).toBe("Nein");
expect(checked("Weiselzellen")).toBe("Nicht geprüft");
});
it("clears an observation back to not checked by sending null", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog();
await userEvent.click(
within(screen.getByRole("radiogroup", { name: "Stifte vorhanden" })).getByRole("radio", {
name: "Nicht geprüft",
}),
);
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", {
name: "Nicht geprüft",
}),
);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({
eggsPresent: null,
queenSeen: false,
queenCellsPresent: null,
feedStatus: null,
});
});
it("clears an emptied number and an un-tapped rating by sending null", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog();
await userEvent.clear(screen.getByLabelText(/^Volksstärke/));
const gentleness = screen.getByRole("radiogroup", { name: /^Sanftmut/ });
await userEvent.click(within(gentleness).getByRole("radio", { name: "2 von 5" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({
colonyStrength: null,
gentleness: null,
broodPattern: 3,
weather: null,
notes: null,
});
});
it("shows a missing date next to the field and does not save", async () => {
await openDialog();
@@ -11,7 +11,8 @@ import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { RatingScale } from "@/components/form/rating-scale";
import { ToggleChip } from "@/components/form/toggle-chip";
import { ChoiceGroup } from "@/components/form/choice-group";
import { TriStateField } from "@/components/form/tri-state-field";
import {
Dialog,
DialogContent,
@@ -19,10 +20,9 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
FEED_STATUS_OPTIONS,
FeedStatus,
INSPECTION_OBSERVATIONS,
InspectionFormValues,
toInspectionInput,
} from "../../inspections/new/inspection-form-values";
@@ -42,17 +42,18 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
colonyStrength: inspection.colonyStrength?.toString() ?? "",
broodAmount: inspection.broodAmount ?? "",
broodPattern: inspection.broodPattern,
openBroodPresent: inspection.openBroodPresent ?? false,
cappedBroodPresent: inspection.cappedBroodPresent ?? false,
queenSeen: inspection.queenSeen ?? false,
eggsPresent: inspection.eggsPresent ?? true,
queenCellsPresent: inspection.queenCellsPresent ?? false,
// null = "Nicht geprüft" stays null; it must not become a "Nein".
openBroodPresent: inspection.openBroodPresent,
cappedBroodPresent: inspection.cappedBroodPresent,
queenSeen: inspection.queenSeen,
eggsPresent: inspection.eggsPresent,
queenCellsPresent: inspection.queenCellsPresent,
swarmMood: inspection.swarmMood,
gentleness: inspection.gentleness,
combBehavior: inspection.combBehavior,
// Kept verbatim even if it is not one of the three known values, so
// saving without touching it never changes it.
feedStatus: (inspection.feedStatus ?? "ausreichend") as FeedStatus,
feedStatus: inspection.feedStatus,
honeySuperStatus: inspection.honeySuperStatus ?? "",
healthIssues: inspection.healthIssues ?? "",
varroaIssues: inspection.varroaIssues ?? "",
@@ -158,35 +159,22 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
</Field>
</FormSection>
<FormSection step={2} title="Königin & Brut" description="Antippen, was du gesehen hast.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ToggleChip
label="Königin gesehen"
checked={values.queenSeen}
onChange={(checked) => set("queenSeen", checked)}
/>
<ToggleChip
label="Stifte vorhanden"
checked={values.eggsPresent}
onChange={(checked) => set("eggsPresent", checked)}
/>
<ToggleChip
label="Offene Brut"
checked={values.openBroodPresent}
onChange={(checked) => set("openBroodPresent", checked)}
/>
<ToggleChip
label="Verdeckelte Brut"
checked={values.cappedBroodPresent}
onChange={(checked) => set("cappedBroodPresent", checked)}
/>
<ToggleChip
label="Weiselzellen"
checked={values.queenCellsPresent}
onChange={(checked) => set("queenCellsPresent", checked)}
/>
<FormSection
step={2}
title="Königin & Brut"
description="Nur beantworten, was du nachgesehen hast – der Rest bleibt „Nicht geprüft“."
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{INSPECTION_OBSERVATIONS.map((observation) => (
<TriStateField
key={observation.key}
label={observation.label}
value={values[observation.key]}
onChange={(answer) => set(observation.key, answer)}
/>
))}
</div>
<Field label="Brutbild">
<Field label="Brutbild" group>
{({ id, labelId, describedBy }) => (
<RatingScale
id={id}
@@ -228,7 +216,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
)}
</Field>
{RATINGS.map((rating) => (
<Field key={rating.key} label={rating.label}>
<Field key={rating.key} label={rating.label} group>
{({ id, labelId }) => (
<RatingScale
id={id}
@@ -244,28 +232,18 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
</FormSection>
<FormSection step={4} title="Futter">
<div role="radiogroup" aria-label="Futterstatus" className="grid grid-cols-3 gap-2">
{FEED_STATUS_OPTIONS.map((option) => {
const selected = values.feedStatus === option.value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => set("feedStatus", option.value)}
className={cn(
"min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors",
selected
? "border-primary bg-primary font-medium text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option.label}
</button>
);
})}
</div>
<Field label="Futterstatus" group>
{({ id, labelId }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
)}
</Field>
</FormSection>
<FormSection
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SwarmControlEditDialog } from "./swarm-control-edit-dialog";
import { updateSwarmControlCheck } from "@/lib/swarm-control/api";
import { fetchColonies } from "@/lib/colonies/api";
import type { SwarmControlCheck } from "@/lib/swarm-control/types";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/swarm-control/api", () => ({ updateSwarmControlCheck: vi.fn() }));
const check: SwarmControlCheck = {
id: "s-1",
colonyId: "colony-1",
userId: "u-1",
date: "2026-05-10T00:00:00.000Z",
queenCellsFound: 3,
queenCellsCapped: false,
swarmMood: "LOW",
actionTaken: "NONE",
notes: "Spielnäpfchen",
nextCheckDate: "2026-05-17T00:00:00.000Z",
createdAt: "2026-05-10T00:00:00.000Z",
};
describe("SwarmControlEditDialog", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(updateSwarmControlCheck).mockReset();
vi.mocked(updateSwarmControlCheck).mockResolvedValue(check);
});
it("sends an emptied count, next check date and notes as null", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<SwarmControlEditDialog check={check} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
await screen.findByRole("dialog");
await userEvent.clear(screen.getByLabelText(/^Weiselzellen gefunden/));
await userEvent.clear(screen.getByLabelText(/^Nächste Kontrolle/));
await userEvent.clear(screen.getByLabelText(/^Notizen/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateSwarmControlCheck).mock.calls[0];
expect(input).toMatchObject({
queenCellsFound: null,
nextCheckDate: null,
notes: null,
});
});
});
@@ -5,16 +5,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateSwarmControlCheck } from "@/lib/swarm-control/api";
import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels";
import { SwarmControlAction, SwarmControlCheck, SwarmMood } from "@/lib/swarm-control/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
@@ -32,6 +27,7 @@ import {
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {
const queryClient = useQueryClient();
@@ -51,13 +47,13 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
mutationFn: () =>
updateSwarmControlCheck(check.id, {
colonyId,
date: new Date(date).toISOString(),
queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined,
date: toApiDate(date),
queenCellsFound: emptyToNull(queenCellsFound, Number),
queenCellsCapped,
swarmMood,
actionTaken,
nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined,
notes: notes || undefined,
nextCheckDate: emptyToNull(nextCheckDate, toApiDate),
notes: emptyToNull(notes),
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
@@ -138,21 +134,12 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
/>
<Field label="Schwarmstimmung" required>
{({ id }) => (
<Select
<EnumSelect
id={id}
value={swarmMood}
onValueChange={(value) => setSwarmMood((value ?? swarmMood) as SwarmMood)}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{SWARM_MOOD_LABELS[swarmMood]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_MOOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setSwarmMood}
labels={SWARM_MOOD_LABELS}
/>
)}
</Field>
</FormSection>
@@ -160,23 +147,12 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
<FormSection step={3} title="Maßnahme">
<Field label="Maßnahme" required>
{({ id }) => (
<Select
<EnumSelect
id={id}
value={actionTaken}
onValueChange={(value) =>
setActionTaken((value ?? actionTaken) as SwarmControlAction)
}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{SWARM_ACTION_LABELS[actionTaken]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]).map((option) => (
<SelectItem key={option} value={option}>
{SWARM_ACTION_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setActionTaken}
labels={SWARM_ACTION_LABELS}
/>
)}
</Field>
<Field label="Nächste Kontrolle">
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTreatment } from "@/lib/treatments/api";
import { Treatment } from "@/lib/treatments/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -23,6 +24,7 @@ import {
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
interface FieldErrors {
date?: string;
@@ -56,20 +58,20 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
mutationFn: () =>
updateTreatment(treatment.id, {
colonyId,
date: new Date(date).toISOString(),
startDate: startDate ? new Date(startDate).toISOString() : undefined,
endDate: endDate ? new Date(endDate).toISOString() : undefined,
date: toApiDate(date),
startDate: emptyToNull(startDate, toApiDate),
endDate: emptyToNull(endDate, toApiDate),
product,
activeIngredient: activeIngredient || undefined,
amount: amount || undefined,
method: method || undefined,
reason: reason || undefined,
batchNumber: batchNumber || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
supplier: supplier.trim(),
purchaseProof: purchaseProof.trim(),
prescribingVet: prescribingVet.trim(),
activeIngredient: emptyToNull(activeIngredient),
amount: emptyToNull(amount),
method: emptyToNull(method),
reason: emptyToNull(reason),
batchNumber: emptyToNull(batchNumber),
notes: emptyToNull(notes),
withdrawalPeriodDays: emptyToNull(withdrawalPeriodDays, Number),
supplier: emptyToNull(supplier),
purchaseProof: emptyToNull(purchaseProof),
prescribingVet: emptyToNull(prescribingVet),
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { VarroaEditDialog } from "./varroa-edit-dialog";
import { updateVarroaMeasurement } from "@/lib/varroa/api";
import { fetchColonies } from "@/lib/colonies/api";
import type { VarroaMeasurement } from "@/lib/varroa/types";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/varroa/api", () => ({ updateVarroaMeasurement: vi.fn() }));
const measurement: VarroaMeasurement = {
id: "v-1",
colonyId: "colony-1",
userId: "u-1",
date: "2026-06-01T00:00:00.000Z",
method: "NATURAL_MITE_FALL",
durationMinutes: 4320,
miteCount: 12,
calculatedValue: 4,
assessment: "mittel",
notes: "Windel verrutscht",
createdAt: "2026-06-01T00:00:00.000Z",
};
async function openDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<VarroaEditDialog measurement={measurement} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
return screen.findByRole("dialog");
}
describe("VarroaEditDialog", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(updateVarroaMeasurement).mockReset();
vi.mocked(updateVarroaMeasurement).mockResolvedValue(measurement);
});
it("sends emptied optional fields as null so a wrong value can be removed", async () => {
await openDialog();
for (const label of ["Milbenzahl", "Zeitraum (Minuten)", "Milben pro Tag", "Einschätzung", "Notizen"]) {
await userEvent.clear(screen.getByLabelText(new RegExp(`^${label.replace(/[()]/g, "\\$&")}`)));
}
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({
miteCount: null,
durationMinutes: null,
calculatedValue: null,
assessment: null,
notes: null,
});
});
it("keeps filled-in values, a count of zero included", async () => {
await openDialog();
const miteCount = screen.getByLabelText(/^Milbenzahl/);
await userEvent.clear(miteCount);
await userEvent.type(miteCount, "0");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({
miteCount: 0,
durationMinutes: 4320,
assessment: "mittel",
notes: "Windel verrutscht",
});
});
});
@@ -5,16 +5,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateVarroaMeasurement } from "@/lib/varroa/api";
import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { ColonySelectField } from "@/components/colony-select-field";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
@@ -31,6 +26,7 @@ import {
EditTriggerButton,
hasAnyValue,
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
@@ -54,13 +50,13 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
mutationFn: () =>
updateVarroaMeasurement(measurement.id, {
colonyId,
date: new Date(date).toISOString(),
date: toApiDate(date),
method,
miteCount: miteCount ? Number(miteCount) : undefined,
durationMinutes: durationMinutes ? Number(durationMinutes) : undefined,
calculatedValue: calculatedValue ? Number(calculatedValue) : undefined,
assessment: assessment || undefined,
notes: notes || undefined,
miteCount: emptyToNull(miteCount, Number),
durationMinutes: emptyToNull(durationMinutes, Number),
calculatedValue: emptyToNull(calculatedValue, Number),
assessment: emptyToNull(assessment),
notes: emptyToNull(notes),
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
@@ -122,21 +118,13 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
<FormSection step={2} title="Messung">
<Field label="Methode" required>
{({ id }) => (
<Select
<EnumSelect
id={id}
value={method}
onValueChange={(value) => setMethod((value ?? method) as VarroaMethod)}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{VARROA_METHOD_LABELS[method]}</SelectValue>
</SelectTrigger>
<SelectContent>
{VARROA_METHODS.map((option) => (
<SelectItem key={option} value={option}>
{VARROA_METHOD_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setMethod}
values={VARROA_METHODS}
labels={VARROA_METHOD_LABELS}
/>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
@@ -1,4 +1,5 @@
import type { CreateColonyInput, ColonyType } from "@/lib/colonies/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ColonyFormValues {
@@ -29,15 +30,13 @@ export function validateColony(values: ColonyFormValues): ColonyFormErrors {
return errors;
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
export function toColonyInput(values: ColonyFormValues): CreateColonyInput {
return {
code: values.code.trim(),
apiaryId: values.apiaryId,
colonyType: values.colonyType,
hiveType: textOrUndefined(values.hiveType),
originText: textOrUndefined(values.originText),
// Not filled in = null, the same convention as every edit form.
hiveType: emptyToNull(values.hiveType),
originText: emptyToNull(values.originText),
};
}
@@ -57,8 +57,9 @@ describe("NewColonyPage", () => {
code: "V-07",
apiaryId: "apiary-1",
colonyType: "PRODUCTION_COLONY",
hiveType: undefined,
originText: undefined,
// Not filled in = null, the same convention as every edit form.
hiveType: null,
originText: null,
});
expect(push).toHaveBeenCalledWith("/colonies/colony-9");
});
@@ -6,20 +6,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Layers } from "lucide-react";
import { createColony } from "@/lib/colonies/api";
import { ColonyType } from "@/lib/colonies/types";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -149,61 +143,30 @@ export default function NewColonyPage() {
}
>
{({ id, describedBy, invalid }) => (
<Select
<RecordSelect
id={id}
dataField="apiaryId"
describedBy={describedBy}
invalid={invalid}
value={values.apiaryId}
onValueChange={(value) => set("apiaryId", value ?? "")}
>
<SelectTrigger
id={id}
data-field="apiaryId"
className="w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
apiariesLoading
? "Bienenstände werden geladen…"
: "Bienenstand wählen"
}
>
{(value: string) =>
activeApiaries.find((apiary) => apiary.id === value)
?.name ?? null
}
</SelectValue>
</SelectTrigger>
<SelectContent>
{activeApiaries.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(value) => set("apiaryId", value)}
items={apiaries}
getLabel={(apiary) => apiary.name}
placeholder={
apiariesLoading ? "Bienenstände werden geladen…" : "Bienenstand wählen"
}
/>
)}
</Field>
<Field label="Volkstyp">
{({ id }) => (
<Select
<EnumSelect
id={id}
value={values.colonyType}
onValueChange={(value) =>
set("colonyType", value as ColonyType)
}
>
<SelectTrigger id={id} className="w-full">
<SelectValue>
{COLONY_TYPE_LABELS[values.colonyType]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{COLONY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{COLONY_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(value) => set("colonyType", value)}
values={COLONY_TYPES}
labels={COLONY_TYPE_LABELS}
/>
)}
</Field>
</FormSection>
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { fetchColonies } from "@/lib/colonies/api";
import { Colony, ColonyStatus } from "@/lib/colonies/types";
import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { ENDED_COLONY_STATUSES, LIVING_COLONY_STATUSES } from "@/lib/colonies/status";
import { exportColoniesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone";
import { Button } from "@/components/ui/button";
@@ -23,16 +24,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const ACTIVE_STATUS_OPTIONS: ColonyStatus[] = ["ACTIVE", "WEAK", "CONSPICUOUS", "QUEENLESS"];
const HISTORY_STATUS_OPTIONS: ColonyStatus[] = ["DISSOLVED", "SOLD", "DECEASED", "MERGED"];
import { EnumSelect } from "@/components/form/enum-select";
export default function ColoniesPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active");
@@ -53,7 +45,7 @@ export default function ColoniesPage() {
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
const bucketColonies = tab === "active" ? activeColonies : historyColonies;
const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies;
const statusOptions = tab === "active" ? ACTIVE_STATUS_OPTIONS : HISTORY_STATUS_OPTIONS;
const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
@@ -95,24 +87,15 @@ export default function ColoniesPage() {
activeCount={activeColonies.length}
historyCount={historyColonies.length}
/>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))}
>
<SelectTrigger className="w-40" aria-label="Nach Status filtern">
<SelectValue placeholder="Status">
{status ? COLONY_STATUS_LABELS[status] : "Alle Status"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{statusOptions.map((option) => (
<SelectItem key={option} value={option}>
{COLONY_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
ariaLabel="Nach Status filtern"
className="w-40"
value={status}
onChange={setStatus}
values={statusOptions}
labels={COLONY_STATUS_LABELS}
noneLabel="Alle Status"
/>
</div>
<QueryState query={coloniesQuery} what="Völker">
@@ -0,0 +1,45 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { initialBottlingValues } from "./bottling/new/bottling-form-values";
import { initialExtractionValues } from "./extractions/new/extraction-form-values";
import { initialFeedingValues } from "./feedings/new/feeding-form-values";
import { initialHarvestValues } from "./harvests/new/harvest-form-values";
import { initialInspectionValues } from "./inspections/new/inspection-form-values";
import { initialSwarmControlValues } from "./swarm-control/new/swarm-control-form-values";
import { initialTreatmentValues } from "./treatments/new/treatment-form-values";
import { initialVarroaValues } from "./varroa/new/varroa-form-values";
/**
* Every capture form prefills the beekeeper's local "today". At 00:30 in
* Berlin the UTC date is still yesterday - that must not leak into a form.
*/
describe("default date of every capture form", () => {
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = "Europe/Berlin";
});
afterAll(() => {
process.env.TZ = originalTz;
});
afterEach(() => {
vi.useRealTimers();
});
it.each([
["Abfüllung", () => initialBottlingValues("hb-1").date],
["Schleuderung", () => initialExtractionValues(null).date],
["Fütterung", () => initialFeedingValues("c-1").date],
["Ernte", () => initialHarvestValues("a-1").date],
["Durchsicht", () => initialInspectionValues("c-1").date],
["Schwarmkontrolle", () => initialSwarmControlValues("c-1").date],
["Behandlung", () => initialTreatmentValues("c-1").date],
["Varroa", () => initialVarroaValues("c-1").date],
])("%s starts on the local day shortly after midnight", (_name, defaultDate) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-11T22:30:00.000Z")); // 00:30 in Berlin
expect(defaultDate()).toBe("2026-10-12");
});
});
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateEquipment } from "@/lib/equipment/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Equipment, EquipmentCondition } from "@/lib/equipment/types";
import {
EQUIPMENT_CONDITIONS,
@@ -13,13 +14,7 @@ import {
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 { EnumSelect } from "@/components/form/enum-select";
import {
Dialog,
DialogContent,
@@ -27,6 +22,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates";
/**
* Corrects every field of an inventory item. Only the condition used to be
@@ -63,14 +59,10 @@ function EquipmentEditForm({
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,
manufacturer: emptyToNull(manufacturer),
location: emptyToNull(location),
purchaseDate: emptyToNull(purchaseDate, toApiDate),
maintenanceDate: emptyToNull(maintenanceDate, toApiDate),
condition,
}),
onSuccess: () => {
@@ -152,23 +144,13 @@ function EquipmentEditForm({
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="equipment-edit-condition">Zustand</Label>
<Select
<EnumSelect
id="equipment-edit-condition"
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>
onChange={setCondition}
values={EQUIPMENT_CONDITIONS}
labels={EQUIPMENT_CONDITION_LABELS}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
@@ -8,13 +8,7 @@ import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
import { EquipmentEditDialog } from "./equipment-edit-dialog";
import { EquipmentCondition } from "@/lib/equipment/types";
import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { errorMessage } from "@/lib/forms/error-message";
@@ -80,23 +74,14 @@ export default function EquipmentDetailPage() {
) : null}
<div className="flex flex-col gap-2 pt-2">
<span className="text-muted-foreground">Zustand ändern</span>
<Select
<EnumSelect
ariaLabel="Zustand ändern"
className="sm:w-48"
value={equipment.condition}
onValueChange={(value) =>
value ? updateMutation.mutate(value as EquipmentCondition) : undefined
}
>
<SelectTrigger className="w-full sm:w-48" aria-label="Zustand ändern">
<SelectValue>{EQUIPMENT_CONDITION_LABELS[equipment.condition]}</SelectValue>
</SelectTrigger>
<SelectContent>
{EQUIPMENT_CONDITIONS.map((condition) => (
<SelectItem key={condition} value={condition}>
{EQUIPMENT_CONDITION_LABELS[condition]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(condition) => updateMutation.mutate(condition)}
values={EQUIPMENT_CONDITIONS}
labels={EQUIPMENT_CONDITION_LABELS}
/>
</div>
</CardContent>
</Card>
@@ -1,4 +1,6 @@
import type { CreateEquipmentInput } from "@/lib/equipment/types";
import { toApiDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface EquipmentFormValues {
@@ -37,20 +39,15 @@ export function validateEquipment(
return errors;
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const dateOrUndefined = (value: string) =>
value ? new Date(value).toISOString() : undefined;
export function toEquipmentInput(
values: EquipmentFormValues,
): CreateEquipmentInput {
return {
inventoryNumber: values.inventoryNumber.trim(),
name: values.name.trim(),
manufacturer: textOrUndefined(values.manufacturer),
purchaseDate: dateOrUndefined(values.purchaseDate),
location: textOrUndefined(values.location),
maintenanceDate: dateOrUndefined(values.maintenanceDate),
manufacturer: emptyToNull(values.manufacturer),
purchaseDate: emptyToNull(values.purchaseDate, toApiDate),
location: emptyToNull(values.location),
maintenanceDate: emptyToNull(values.maintenanceDate, toApiDate),
};
}
@@ -51,10 +51,10 @@ describe("NewEquipmentPage", () => {
expect(vi.mocked(createEquipment).mock.calls[0][0]).toEqual({
inventoryNumber: "INV-7",
name: "Schleuder",
manufacturer: undefined,
purchaseDate: undefined,
location: undefined,
maintenanceDate: undefined,
manufacturer: null,
purchaseDate: null,
location: null,
maintenanceDate: null,
});
expect(push).toHaveBeenCalledWith("/equipment/eq-1");
});
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateExtraction } from "@/lib/extractions/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Extraction } from "@/lib/extractions/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,6 +19,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates";
export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) {
const queryClient = useQueryClient();
@@ -36,12 +38,12 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction })
mutationFn: () =>
updateExtraction(extraction.id, {
harvestIds,
date: new Date(date).toISOString(),
amount: amount ? Number(amount) : undefined,
room: room || undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
date: toApiDate(date),
amount: emptyToNull(amount, Number),
room: emptyToNull(room),
waterContent: emptyToNull(waterContent, Number),
temperature: emptyToNull(temperature, Number),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] });
@@ -1,4 +1,6 @@
import type { createExtraction } from "@/lib/extractions/api";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ExtractionFormValues {
@@ -10,15 +12,11 @@ export interface ExtractionFormValues {
notes: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialExtractionValues(
preselectedHarvestId: string | null,
): ExtractionFormValues {
return {
date: todayIsoDate(),
date: todayLocalIsoDate(),
harvestIds: preselectedHarvestId ? [preselectedHarvestId] : [],
amount: "",
room: "",
@@ -27,20 +25,15 @@ export function initialExtractionValues(
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toExtractionInput(
values: ExtractionFormValues,
): Parameters<typeof createExtraction>[0] {
return {
date: new Date(values.date).toISOString(),
date: toApiDate(values.date),
harvestIds: values.harvestIds,
amount: numberOrUndefined(values.amount),
room: textOrUndefined(values.room),
waterContent: numberOrUndefined(values.waterContent),
notes: textOrUndefined(values.notes),
amount: emptyToNull(values.amount, Number),
room: emptyToNull(values.room),
waterContent: emptyToNull(values.waterContent, Number),
notes: emptyToNull(values.notes),
};
}
@@ -63,7 +63,7 @@ describe("NewExtractionPage", () => {
amount: 18.5,
waterContent: 17,
});
expect(input.room).toBeUndefined();
expect(input.room).toBeNull();
expect(push).toHaveBeenCalledWith("/extractions/e-1");
});
@@ -158,6 +158,7 @@ export default function NewExtractionPage() {
<Field
label="Ernten"
required
group
error={fieldErrors.harvestIds}
hint={
harvests?.length === 0
@@ -1,5 +1,7 @@
import type { createFeeding } from "@/lib/feedings/api";
import type { FeedType } from "@/lib/feedings/types";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Common units as one tap each; the API still receives a plain string. */
export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [
@@ -20,14 +22,10 @@ export interface FeedingFormValues {
notes: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialFeedingValues(colonyId: string): FeedingFormValues {
return {
colonyId,
date: todayIsoDate(),
date: todayLocalIsoDate(),
feedType: "SUGAR_WATER",
amount: "",
unit: "l",
@@ -37,20 +35,17 @@ export function initialFeedingValues(colonyId: string): FeedingFormValues {
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
export function toFeedingInput(
values: FeedingFormValues,
): Parameters<typeof createFeeding>[0] {
return {
colonyId: values.colonyId,
date: new Date(values.date).toISOString(),
date: toApiDate(values.date),
feedType: values.feedType,
amount: Number(values.amount),
unit: values.unit,
product: textOrUndefined(values.product),
batchNumber: textOrUndefined(values.batchNumber),
notes: textOrUndefined(values.notes),
product: emptyToNull(values.product),
batchNumber: emptyToNull(values.batchNumber),
notes: emptyToNull(values.notes),
};
}
@@ -71,7 +71,7 @@ describe("NewFeedingPage", () => {
amount: 2.5,
unit: "kg",
});
expect(input.product).toBeUndefined();
expect(input.product).toBeNull();
expect(options?.idempotencyKey).toBeTruthy();
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
@@ -2,10 +2,9 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Utensils } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createFeeding } from "@/lib/feedings/api";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { ApiError } from "@/lib/auth/api-client";
@@ -16,20 +15,14 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { cn } from "@/lib/utils";
import {
FEEDING_UNIT_OPTIONS,
FeedingFormValues,
@@ -54,71 +47,13 @@ function validate(values: FeedingFormValues): FieldErrors {
return errors;
}
interface ChoiceGroupProps<T extends string> {
id: string;
labelledBy: string;
options: { value: T; label: string }[];
value: T;
onChange: (value: T) => void;
className?: string;
}
/** One large button per option: a single tap with gloves, no dropdown. */
function ChoiceGroup<T extends string>({
id,
labelledBy,
options,
value,
onChange,
className,
}: ChoiceGroupProps<T>) {
return (
<div
id={id}
role="radiogroup"
aria-labelledby={labelledBy}
className={cn("grid gap-2", className)}
>
{options.map((option) => {
const selected = value === option.value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(option.value)}
className={cn(
"min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors",
selected
? "border-primary bg-primary font-medium text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
const FEED_TYPE_OPTIONS = FEED_TYPES.map((type) => ({
value: type,
label: FEED_TYPE_LABELS[type],
}));
const FEED_TYPE_OPTIONS = choiceOptions(FEED_TYPE_LABELS, FEED_TYPES);
export default function NewFeedingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: colonies, isLoading: coloniesLoading } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const activeColonies =
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const [values, setValues] = useState<FeedingFormValues>(() =>
initialFeedingValues(searchParams.get("colonyId") ?? ""),
@@ -221,46 +156,12 @@ export default function NewFeedingPage() {
noValidate
>
<FormSection step={1} title="Was, welches Volk, wann?">
<Field
label="Volk"
required
<LivingColonySelectField
triggerId="feeding-colony"
value={values.colonyId}
onChange={(colonyId) => set("colonyId", colonyId)}
error={fieldErrors.colonyId}
hint={
!coloniesLoading && activeColonies.length === 0
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
: undefined
}
>
{({ describedBy, invalid }) => (
<Select
value={values.colonyId}
onValueChange={(value) => set("colonyId", value ?? "")}
>
<SelectTrigger
id="feeding-colony"
data-field="colonyId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
coloniesLoading
? "Völker werden geladen…"
: "Volk wählen"
}
/>
</SelectTrigger>
<SelectContent>
{activeColonies.map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
/>
<Field label="Datum" required error={fieldErrors.date}>
{({ id, describedBy, invalid }) => (
<Input
@@ -276,7 +177,7 @@ export default function NewFeedingPage() {
/>
)}
</Field>
<Field label="Futterart" required>
<Field label="Futterart" required group>
{({ id, labelId }) => (
<ChoiceGroup
id={id}
@@ -306,7 +207,7 @@ export default function NewFeedingPage() {
/>
)}
</Field>
<Field label="Einheit" required>
<Field label="Einheit" required group>
{({ id, labelId }) => (
<ChoiceGroup
id={id}
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHarvest } from "@/lib/harvests/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Harvest } from "@/lib/harvests/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
@@ -12,13 +13,7 @@ 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 { RecordSelect } from "@/components/form/record-select";
import {
Dialog,
DialogContent,
@@ -26,6 +21,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates";
export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
const queryClient = useQueryClient();
@@ -72,12 +68,12 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
updateHarvest(harvest.id, {
apiaryId,
colonyIds,
date: new Date(date).toISOString(),
honeyType: honeyType || undefined,
grossWeight: grossWeight ? Number(grossWeight) : undefined,
netWeight: netWeight ? Number(netWeight) : undefined,
honeySuperCount: honeySuperCount ? Number(honeySuperCount) : undefined,
notes: notes || undefined,
date: toApiDate(date),
honeyType: emptyToNull(honeyType),
grossWeight: emptyToNull(grossWeight, Number),
netWeight: emptyToNull(netWeight, Number),
honeySuperCount: emptyToNull(honeySuperCount, Number),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
@@ -113,21 +109,14 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="harvest-edit-apiary">Bienenstand</Label>
<Select
<RecordSelect
id="harvest-edit-apiary"
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? apiaryId)}
>
<SelectTrigger id="harvest-edit-apiary" className="w-full">
<SelectValue placeholder="Bienenstand wählen" />
</SelectTrigger>
<SelectContent>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setApiaryId}
items={apiaries}
getLabel={(apiary) => apiary.name}
placeholder="Bienenstand wählen"
/>
</div>
<div className="flex flex-col gap-2">
<Label>Beteiligte Völker</Label>
@@ -1,4 +1,6 @@
import type { createHarvest } from "@/lib/harvests/api";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface HarvestFormValues {
@@ -12,14 +14,10 @@ export interface HarvestFormValues {
notes: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialHarvestValues(apiaryId: string): HarvestFormValues {
return {
apiaryId,
date: todayIsoDate(),
date: todayLocalIsoDate(),
colonyIds: [],
honeyType: "",
honeySuperCount: "",
@@ -29,22 +27,17 @@ export function initialHarvestValues(apiaryId: string): HarvestFormValues {
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toHarvestInput(
values: HarvestFormValues,
): Parameters<typeof createHarvest>[0] {
return {
apiaryId: values.apiaryId,
date: new Date(values.date).toISOString(),
date: toApiDate(values.date),
colonyIds: values.colonyIds,
honeyType: textOrUndefined(values.honeyType),
honeySuperCount: numberOrUndefined(values.honeySuperCount),
grossWeight: numberOrUndefined(values.grossWeight),
netWeight: numberOrUndefined(values.netWeight),
notes: textOrUndefined(values.notes),
honeyType: emptyToNull(values.honeyType),
honeySuperCount: emptyToNull(values.honeySuperCount, Number),
grossWeight: emptyToNull(values.grossWeight, Number),
netWeight: emptyToNull(values.netWeight, Number),
notes: emptyToNull(values.notes),
};
}
@@ -70,11 +70,60 @@ describe("NewHarvestPage", () => {
grossWeight: 30.5,
netWeight: 22,
});
expect(input.honeyType).toBeUndefined();
expect(input.honeyType).toBeNull();
expect(options?.idempotencyKey).toBeTruthy();
expect(push).toHaveBeenCalledWith("/harvests/h-1");
});
it("offers every living colony of the apiary, weak and queenless ones included", async () => {
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
{ id: "colony-3", code: "V-03", status: "QUEENLESS", apiaryId: "apiary-1" },
{ id: "colony-4", code: "V-04", status: "WEAK", apiaryId: "apiary-1" },
{ id: "colony-5", code: "V-05", status: "DISSOLVED", apiaryId: "apiary-1" },
{ id: "colony-6", code: "V-06", status: "ACTIVE", apiaryId: "apiary-2" },
] as never);
renderPage();
await screen.findByRole("checkbox", { name: "V-01" });
expect(
screen.getAllByRole("checkbox").map((chip) => chip.textContent),
).toEqual(["V-01", "V-03 (Weisellos)", "V-04 (Schwach)"]);
});
it("checks the withdrawal period at the harvest date and again when it changes", async () => {
vi.mocked(fetchWithdrawalStatus).mockImplementation(async (_ids, at) =>
at === "2026-07-01"
? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", product: "Oxuvar" }]
: [],
);
renderPage();
await screen.findByRole("checkbox", { name: "V-01" });
const date = screen.getByLabelText(/^Datum/) as HTMLInputElement;
expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith(
["colony-1", "colony-2"],
date.value,
);
await userEvent.clear(date);
await userEvent.type(date, "2026-07-01");
expect(await screen.findByText(/Wartezeit \(Oxuvar\) bis 10\.7\.2026/)).toBeInTheDocument();
expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith(
["colony-1", "colony-2"],
"2026-07-01",
);
});
it("says so when the withdrawal periods could not be checked", async () => {
vi.mocked(fetchWithdrawalStatus).mockRejectedValue(new Error("offline"));
renderPage();
expect(
await screen.findByText(/Wartezeiten konnten nicht geprüft werden/),
).toBeInTheDocument();
});
it("shows an inline error and focuses the colonies when none is chosen", async () => {
renderPage();
await screen.findByRole("checkbox", { name: "V-01" });
@@ -2,13 +2,21 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { Boxes, AlertTriangle } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { isLivingColony } from "@/lib/colonies/status";
import { createHarvest } from "@/lib/harvests/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
@@ -17,13 +25,7 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -41,6 +43,7 @@ type RequiredKey = "apiaryId" | "date" | "colonyIds";
type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["apiaryId", "date", "colonyIds"];
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function validate(values: HarvestFormValues): FieldErrors {
const errors: FieldErrors = {};
@@ -75,17 +78,21 @@ export default function NewHarvestPage() {
const draft = useFormDraft("harvest-new", values, dirty);
const apiaryColonies = (colonies ?? []).filter(
(colony) =>
colony.apiaryId === values.apiaryId && colony.status === "ACTIVE",
(colony) => colony.apiaryId === values.apiaryId && isLivingColony(colony),
);
const apiaryColonyIds = apiaryColonies.map((colony) => colony.id);
const { data: withdrawalStatuses } = useQuery({
queryKey: ["treatments", "withdrawal-status", apiaryColonyIds],
queryFn: () => fetchWithdrawalStatus(apiaryColonyIds),
// Checked at the harvest date, not "now": honey taken on a past or future
// day is affected by the withdrawal windows of that day. An empty or
// unfinished date falls back to today rather than hiding every warning.
const withdrawalAt = ISO_DATE.test(values.date) ? values.date : todayLocalIsoDate();
const withdrawalQuery = useQuery({
queryKey: ["treatments", "withdrawal-status", apiaryColonyIds, withdrawalAt],
queryFn: () => fetchWithdrawalStatus(apiaryColonyIds, withdrawalAt),
placeholderData: keepPreviousData,
enabled: apiaryColonyIds.length > 0,
});
const withdrawalByColony = new Map(
(withdrawalStatuses ?? []).map((status) => [status.colonyId, status]),
(withdrawalQuery.data ?? []).map((status) => [status.colonyId, status]),
);
function set<K extends keyof HarvestFormValues>(
@@ -190,38 +197,25 @@ export default function NewHarvestPage() {
>
<FormSection step={1} title="Wo, wann?">
<Field
id="harvest-apiary"
label="Bienenstand"
required
error={fieldErrors.apiaryId}
>
{({ describedBy, invalid }) => (
<Select
{({ id, describedBy, invalid }) => (
<RecordSelect
id={id}
dataField="apiaryId"
describedBy={describedBy}
invalid={invalid}
value={values.apiaryId}
onValueChange={(value) => selectApiary(value ?? "")}
>
<SelectTrigger
id="harvest-apiary"
data-field="apiaryId"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
apiariesLoading
? "Bienenstände werden geladen…"
: "Bienenstand wählen"
}
/>
</SelectTrigger>
<SelectContent>
{apiaries?.map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={selectApiary}
items={apiaries}
getLabel={(apiary) => apiary.name}
placeholder={
apiariesLoading ? "Bienenstände werden geladen…" : "Bienenstand wählen"
}
/>
)}
</Field>
<Field label="Datum" required error={fieldErrors.date}>
@@ -244,6 +238,7 @@ export default function NewHarvestPage() {
<FormSection step={2} title="Welche Völker?">
<Field
label="Beteiligte Völker"
group
required
error={fieldErrors.colonyIds}
hint={
@@ -269,7 +264,7 @@ export default function NewHarvestPage() {
return (
<div key={colony.id} className="flex flex-col gap-1">
<ToggleChip
label={colony.code}
label={colonyOptionLabel(colony)}
checked={values.colonyIds.includes(colony.id)}
onChange={(checked) =>
toggleColony(colony.id, checked)
@@ -293,6 +288,13 @@ export default function NewHarvestPage() {
</div>
)}
</Field>
{withdrawalQuery.isError ? (
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
Wartezeiten konnten nicht geprüft werden. Vor der Ernte die Behandlungen der
Völker selbst ansehen.
</p>
) : null}
</FormSection>
<FormSection step={3} title="Wie viel?">
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addHoneyMeasurement } from "@/lib/honey-batches/api";
import type { CreateHoneyMeasurementInput } from "@/lib/honey-batches/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -14,21 +15,19 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [waterContent, setWaterContent] = useState("");
const [measuredAt, setMeasuredAt] = useState(todayIsoDate());
const [measuredAt, setMeasuredAt] = useState(todayLocalIsoDate());
const [device, setDevice] = useState("");
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (input: { waterContent: number; measuredAt: string; device?: string }) =>
mutationFn: (input: CreateHoneyMeasurementInput) =>
addHoneyMeasurement(honeyBatchId, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatchId] });
@@ -51,8 +50,8 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
}
mutation.mutate({
waterContent: Number(waterContent),
measuredAt: new Date(measuredAt).toISOString(),
device: device || undefined,
measuredAt: toApiDate(measuredAt),
device: emptyToNull(device),
});
}
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyMeasurement } from "@/lib/honey-batches/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { HoneyMeasurement } from "@/lib/honey-batches/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,6 +18,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates";
/**
* Corrects a water-content reading. The figure decides whether a batch may be
@@ -50,10 +52,10 @@ function EditMeasurementForm({
mutationFn: () =>
updateHoneyMeasurement(measurement.id, {
waterContent: Number(waterContent),
measuredAt: new Date(measuredAt).toISOString(),
device: device || undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
measuredAt: toApiDate(measuredAt),
device: emptyToNull(device),
temperature: emptyToNull(temperature, Number),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchExtraction, updateExtraction } from "@/lib/extractions/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Extraction } from "@/lib/extractions/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,6 +19,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates";
function ExtractionEditForm({
extraction,
@@ -47,12 +49,12 @@ function ExtractionEditForm({
mutationFn: () =>
updateExtraction(extraction.id, {
harvestIds,
date: new Date(date).toISOString(),
amount: amount ? Number(amount) : undefined,
room: room || undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
temperature: temperature ? Number(temperature) : undefined,
notes: notes || undefined,
date: toApiDate(date),
amount: emptyToNull(amount, Number),
room: emptyToNull(room),
waterContent: emptyToNull(waterContent, Number),
temperature: emptyToNull(temperature, Number),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -16,13 +16,8 @@ import { Label } from "@/components/ui/label";
import { EmptyState } from "@/components/empty-state";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Boxes } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { storageContainerOptionLabel } from "@/lib/storage/labels";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Additive to HoneyBatchStorageCard's single-container "Umlagern" flow above
@@ -148,22 +143,14 @@ export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch:
<form className="flex flex-col gap-3 border-t border-border/60 pt-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="allocation-container">Behälter</Label>
<Select
<RecordSelect
id="allocation-container"
value={storageContainerId}
onValueChange={(value) => setStorageContainerId(value ?? "")}
>
<SelectTrigger id="allocation-container">
<SelectValue placeholder="Lagerbehälter wählen" />
</SelectTrigger>
<SelectContent>
{containers?.map((container) => (
<SelectItem key={container.id} value={container.id}>
{container.code}
{container.location ? ` · ${container.location}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setStorageContainerId}
items={containers}
getLabel={storageContainerOptionLabel}
placeholder="Lagerbehälter wählen"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="allocation-amount">Menge (kg)</Label>
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateHoneyBatch } from "@/lib/honey-batches/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { fetchExtractions } from "@/lib/extractions/api";
@@ -12,13 +13,9 @@ import { fetchStorageContainers } from "@/lib/storage/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import { storageContainerOptionLabel } from "@/lib/storage/labels";
import {
Dialog,
DialogContent,
@@ -56,10 +53,10 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
batchNumber,
extractionId,
status,
storageContainerId: storageContainerId || undefined,
honeyType: honeyType || undefined,
amount: amount ? Number(amount) : undefined,
waterContent: waterContent ? Number(waterContent) : undefined,
storageContainerId: emptyToNull(storageContainerId),
honeyType: emptyToNull(honeyType),
amount: emptyToNull(amount, Number),
waterContent: emptyToNull(waterContent, Number),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatch.id] });
@@ -100,41 +97,29 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-extraction">Schleuderung</Label>
<Select
<RecordSelect
id="batch-edit-extraction"
value={extractionId}
onValueChange={(value) => setExtractionId(value ?? extractionId)}
>
<SelectTrigger id="batch-edit-extraction" className="w-full">
<SelectValue placeholder="Schleuderung wählen" />
</SelectTrigger>
<SelectContent>
{(extractions ?? []).map((extraction) => (
<SelectItem key={extraction.id} value={extraction.id}>
{new Date(extraction.date).toLocaleDateString("de-DE")}
{extraction.amount !== null ? ` · ${extraction.amount} kg` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setExtractionId}
items={extractions}
getLabel={(extraction) =>
`${new Date(extraction.date).toLocaleDateString("de-DE")}${
extraction.amount !== null ? ` · ${extraction.amount} kg` : ""
}`
}
placeholder="Schleuderung wählen"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-container">Lagerbehälter</Label>
<Select
<RecordSelect
id="batch-edit-container"
value={storageContainerId}
onValueChange={(value) => setStorageContainerId(value ?? "")}
>
<SelectTrigger id="batch-edit-container" className="w-full">
<SelectValue placeholder="Behälter wählen" />
</SelectTrigger>
<SelectContent>
{(containers ?? []).map((container) => (
<SelectItem key={container.id} value={container.id}>
{container.code}
{container.location ? ` · ${container.location}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setStorageContainerId}
items={containers}
getLabel={storageContainerOptionLabel}
placeholder="Behälter wählen"
/>
<p className="text-xs text-muted-foreground">
Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie
festgehalten.
@@ -142,23 +127,12 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-status">Status</Label>
<Select
<EnumSelect
id="batch-edit-status"
value={status}
onValueChange={(value) => setStatus((value ?? status) as HoneyBatchStatus)}
>
<SelectTrigger id="batch-edit-status" className="w-full">
<SelectValue>{HONEY_BATCH_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(HONEY_BATCH_STATUS_LABELS) as HoneyBatchStatus[]
).map((option) => (
<SelectItem key={option} value={option}>
{HONEY_BATCH_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setStatus}
labels={HONEY_BATCH_STATUS_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-edit-honey-type">Sorte</Label>
@@ -4,19 +4,14 @@ import { FormEvent, useState } from "react";
import { History } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchHoneyBatchRelocations, relocateHoneyBatch } from "@/lib/honey-batches/api";
import { HoneyBatch } from "@/lib/honey-batches/types";
import { HoneyBatch, RelocateHoneyBatchInput } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EmptyState } from "@/components/empty-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { storageContainerOptionLabel } from "@/lib/storage/labels";
import {
Dialog,
DialogContent,
@@ -26,6 +21,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { emptyToNull } from "@/lib/forms/empty-to-null";
export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
@@ -45,7 +41,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
});
const mutation = useMutation({
mutationFn: (input: { storageContainerId: string; notes?: string }) =>
mutationFn: (input: RelocateHoneyBatchInput) =>
relocateHoneyBatch(honeyBatch.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatch.id] });
@@ -69,7 +65,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
setError("Bitte einen Lagerbehälter auswählen.");
return;
}
mutation.mutate({ storageContainerId, notes: notes || undefined });
mutation.mutate({ storageContainerId, notes: emptyToNull(notes) });
}
return (
@@ -85,22 +81,14 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="relocate-container">Ziel-Lagerbehälter</Label>
<Select
<RecordSelect
id="relocate-container"
value={storageContainerId}
onValueChange={(value) => setStorageContainerId(value ?? "")}
>
<SelectTrigger id="relocate-container">
<SelectValue placeholder="Lagerbehälter wählen" />
</SelectTrigger>
<SelectContent>
{containers?.map((container) => (
<SelectItem key={container.id} value={container.id}>
{container.code}
{container.location ? ` · ${container.location}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setStorageContainerId}
items={containers}
getLabel={storageContainerOptionLabel}
placeholder="Lagerbehälter wählen"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="relocate-notes">Notizen</Label>
@@ -15,13 +15,8 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { emptyToNull } from "@/lib/forms/empty-to-null";
export function CreateHoneyBatchDialog() {
const queryClient = useQueryClient();
@@ -61,10 +56,11 @@ export function CreateHoneyBatchDialog() {
return;
}
mutation.mutate({
batchNumber: batchNumber || undefined,
// Empty: the server generates the number (automation format).
batchNumber: emptyToNull(batchNumber),
extractionId,
honeyType: honeyType || undefined,
amount: amount ? Number(amount) : undefined,
honeyType: emptyToNull(honeyType),
amount: emptyToNull(amount, Number),
});
}
@@ -87,21 +83,18 @@ export function CreateHoneyBatchDialog() {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-extraction">Schleuderung</Label>
<Select value={extractionId} onValueChange={(value) => setExtractionId(value ?? "")}>
<SelectTrigger id="batch-extraction">
<SelectValue placeholder="Schleuderung wählen" />
</SelectTrigger>
<SelectContent>
{extractions?.map((extraction) => (
<SelectItem key={extraction.id} value={extraction.id}>
{new Date(extraction.date).toLocaleDateString("de-DE")} ·{" "}
{extraction.harvests
.flatMap((entry) => entry.harvest.colonies.map((c) => c.colony.code))
.join(", ")}
</SelectItem>
))}
</SelectContent>
</Select>
<RecordSelect
id="batch-extraction"
value={extractionId}
onChange={setExtractionId}
items={extractions}
getLabel={(extraction) =>
`${new Date(extraction.date).toLocaleDateString("de-DE")} · ${extraction.harvests
.flatMap((entry) => entry.harvest.colonies.map((c) => c.colony.code))
.join(", ")}`
}
placeholder="Schleuderung wählen"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-honey-type">Honigsorte</Label>
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { initialInspectionValues, toInspectionInput } from "./inspection-form-values";
const OBSERVATIONS = [
"queenSeen",
"eggsPresent",
"openBroodPresent",
"cappedBroodPresent",
"queenCellsPresent",
] as const;
describe("inspection form values", () => {
afterEach(() => {
vi.useRealTimers();
});
it("starts every observation and the feed status as not checked", () => {
const values = initialInspectionValues("colony-1");
for (const key of OBSERVATIONS) {
expect(values[key]).toBeNull();
}
expect(values.feedStatus).toBeNull();
});
it("does not turn an untouched observation into a finding", () => {
const input = toInspectionInput(initialInspectionValues("colony-1"));
for (const key of OBSERVATIONS) {
expect(input[key]).toBeNull();
}
expect(input.feedStatus).toBeNull();
});
it("sends an explicit no as false - the automation reacts only to that", () => {
const input = toInspectionInput({
...initialInspectionValues("colony-1"),
queenSeen: false,
eggsPresent: false,
queenCellsPresent: true,
feedStatus: "kritisch",
});
expect(input).toMatchObject({
queenSeen: false,
eggsPresent: false,
queenCellsPresent: true,
feedStatus: "kritisch",
});
});
it("sends emptied text, numbers and ratings as null so an edit can clear them", () => {
const input = toInspectionInput({
...initialInspectionValues("colony-1"),
weather: " ",
temperature: "",
colonyStrength: "",
notes: "",
gentleness: null,
});
expect(input).toMatchObject({
weather: null,
temperature: null,
colonyStrength: null,
broodPattern: null,
gentleness: null,
honeySuperStatus: null,
measures: null,
notes: null,
});
});
it("keeps filled-in values, zero included, and trims text", () => {
const input = toInspectionInput({
...initialInspectionValues("colony-1"),
weather: " sonnig ",
temperature: "0",
colonyStrength: "8",
swarmMood: 2,
});
expect(input).toMatchObject({ weather: "sonnig", temperature: 0, colonyStrength: 8, swarmMood: 2 });
});
it("keeps 'not checked' through a draft or the offline queue (JSON)", () => {
const values = initialInspectionValues("colony-1");
const restoredDraft = JSON.parse(JSON.stringify(values));
const queuedInput = JSON.parse(JSON.stringify(toInspectionInput(values)));
expect(restoredDraft.queenSeen).toBeNull();
expect(restoredDraft.feedStatus).toBeNull();
expect(queuedInput).toHaveProperty("eggsPresent", null);
expect(queuedInput).toHaveProperty("feedStatus", null);
});
it("prefills today's local date, not the UTC date", () => {
const originalTz = process.env.TZ;
process.env.TZ = "Europe/Berlin";
try {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-11T22:30:00.000Z")); // 00:30 in Berlin
expect(initialInspectionValues("colony-1").date).toBe("2026-10-12");
} finally {
process.env.TZ = originalTz;
}
});
});
@@ -1,14 +1,32 @@
import type { ChoiceOption } from "@/components/form/choice-group";
import type { createInspection } from "@/lib/inspections/api";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
export type FeedStatus = "ausreichend" | "niedrig" | "kritisch";
export const FEED_STATUS_OPTIONS: { value: FeedStatus; label: string }[] = [
/** The three feed levels plus "Nicht geprüft" (null), which is the default. */
export const FEED_STATUS_OPTIONS: readonly ChoiceOption<FeedStatus | null>[] = [
{ value: "ausreichend", label: "Ausreichend" },
{ value: "niedrig", label: "Niedrig" },
{ value: "kritisch", label: "Kritisch" },
{ value: null, label: "Nicht geprüft" },
];
/** Everything the form holds, as plain values that survive a draft save. */
/** The yes/no observations of an inspection, in the order they are asked. */
export const INSPECTION_OBSERVATIONS = [
{ key: "queenSeen", label: "Königin gesehen" },
{ key: "eggsPresent", label: "Stifte vorhanden" },
{ key: "openBroodPresent", label: "Offene Brut" },
{ key: "cappedBroodPresent", label: "Verdeckelte Brut" },
{ key: "queenCellsPresent", label: "Weiselzellen" },
] as const satisfies readonly { key: keyof InspectionFormValues; label: string }[];
/**
* Everything the form holds, as plain values that survive a draft save.
* Yes/no observations and the feed status are null until the beekeeper
* answers them: "not checked" must never be saved as a finding.
*/
export interface InspectionFormValues {
colonyId: string;
date: string;
@@ -17,15 +35,20 @@ export interface InspectionFormValues {
colonyStrength: string;
broodAmount: string;
broodPattern: number | null;
openBroodPresent: boolean;
cappedBroodPresent: boolean;
queenSeen: boolean;
eggsPresent: boolean;
queenCellsPresent: boolean;
openBroodPresent: boolean | null;
cappedBroodPresent: boolean | null;
queenSeen: boolean | null;
eggsPresent: boolean | null;
queenCellsPresent: boolean | null;
swarmMood: number | null;
gentleness: number | null;
combBehavior: number | null;
feedStatus: FeedStatus;
/**
* One of FEED_STATUS_OPTIONS, or null for "Nicht geprüft" - or an older
* free-text value, kept verbatim so saving without touching it changes
* nothing.
*/
feedStatus: string | null;
honeySuperStatus: string;
healthIssues: string;
varroaIssues: string;
@@ -33,30 +56,26 @@ export interface InspectionFormValues {
notes: string;
}
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export function initialInspectionValues(
colonyId: string,
): InspectionFormValues {
return {
colonyId,
date: todayIsoDate(),
date: todayLocalIsoDate(),
weather: "",
temperature: "",
colonyStrength: "",
broodAmount: "",
broodPattern: null,
openBroodPresent: false,
cappedBroodPresent: false,
queenSeen: false,
eggsPresent: true,
queenCellsPresent: false,
openBroodPresent: null,
cappedBroodPresent: null,
queenSeen: null,
eggsPresent: null,
queenCellsPresent: null,
swarmMood: null,
gentleness: null,
combBehavior: null,
feedStatus: "ausreichend",
feedStatus: null,
honeySuperStatus: "",
healthIssues: "",
varroaIssues: "",
@@ -65,35 +84,36 @@ export function initialInspectionValues(
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
/**
* Everything not filled in goes out as null, not omitted: on an edit, null is
* what clears an earlier entry (an omitted field would stay unchanged), and
* "Nicht geprüft" must never turn into a "Ja"/"Nein". Create accepts the
* same nulls, so one mapping serves both.
*/
export function toInspectionInput(
values: InspectionFormValues,
): Parameters<typeof createInspection>[0] {
return {
colonyId: values.colonyId,
date: new Date(values.date).toISOString(),
weather: textOrUndefined(values.weather),
temperature: numberOrUndefined(values.temperature),
colonyStrength: numberOrUndefined(values.colonyStrength),
broodAmount: textOrUndefined(values.broodAmount),
broodPattern: values.broodPattern ?? undefined,
date: toApiDate(values.date),
weather: emptyToNull(values.weather),
temperature: emptyToNull(values.temperature, Number),
colonyStrength: emptyToNull(values.colonyStrength, Number),
broodAmount: emptyToNull(values.broodAmount),
broodPattern: values.broodPattern ?? null,
openBroodPresent: values.openBroodPresent,
cappedBroodPresent: values.cappedBroodPresent,
queenSeen: values.queenSeen,
eggsPresent: values.eggsPresent,
queenCellsPresent: values.queenCellsPresent,
swarmMood: values.swarmMood ?? undefined,
gentleness: values.gentleness ?? undefined,
combBehavior: values.combBehavior ?? undefined,
swarmMood: values.swarmMood ?? null,
gentleness: values.gentleness ?? null,
combBehavior: values.combBehavior ?? null,
feedStatus: values.feedStatus,
honeySuperStatus: textOrUndefined(values.honeySuperStatus),
healthIssues: textOrUndefined(values.healthIssues),
varroaIssues: textOrUndefined(values.varroaIssues),
measures: textOrUndefined(values.measures),
notes: textOrUndefined(values.notes),
honeySuperStatus: emptyToNull(values.honeySuperStatus),
healthIssues: emptyToNull(values.healthIssues),
varroaIssues: emptyToNull(values.varroaIssues),
measures: emptyToNull(values.measures),
notes: emptyToNull(values.notes),
};
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewInspectionPage from "./page";
@@ -53,13 +53,12 @@ describe("NewInspectionPage", () => {
renderPage();
await userEvent.click(
screen.getByRole("checkbox", { name: "Königin gesehen" }),
);
// Order: Brutbild, Sanftmut, Wabensitz, Schwarmstimmung, Futter.
const gentleness = screen.getAllByRole("radiogroup")[1];
await userEvent.click(
gentleness.querySelector('[aria-label="4 von 5"]') as HTMLElement,
within(screen.getByRole("radiogroup", { name: "Königin gesehen" })).getByRole("radio", {
name: "Ja",
}),
);
const gentleness = screen.getByRole("radiogroup", { name: /^Sanftmut/ });
await userEvent.click(within(gentleness).getByRole("radio", { name: "4 von 5" }));
await userEvent.click(
screen.getByRole("button", { name: "Durchsicht speichern" }),
);
@@ -73,13 +72,71 @@ describe("NewInspectionPage", () => {
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
it("warns when neither queen nor eggs were seen", async () => {
it("saves untouched observations and feed as not checked instead of as findings", async () => {
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
} as never);
renderPage();
expect(
within(screen.getByRole("radiogroup", { name: "Stifte vorhanden" })).getByRole("radio", {
name: "Nicht geprüft",
}),
).toHaveAttribute("aria-checked", "true");
await userEvent.click(
screen.getByRole("checkbox", { name: "Stifte vorhanden" }),
screen.getByRole("button", { name: "Durchsicht speichern" }),
);
const [input] = vi.mocked(createInspection).mock.calls[0];
expect(input).toMatchObject({
queenSeen: null,
eggsPresent: null,
openBroodPresent: null,
cappedBroodPresent: null,
queenCellsPresent: null,
feedStatus: null,
});
});
it("warns only when queen and eggs were both explicitly not seen", async () => {
renderPage();
const answer = (group: string, choice: string) =>
userEvent.click(
within(screen.getByRole("radiogroup", { name: group })).getByRole("radio", {
name: choice,
}),
);
expect(screen.queryByText(/Weder Königin noch Stifte gesehen/)).not.toBeInTheDocument();
await answer("Königin gesehen", "Nein");
expect(screen.queryByText(/Weder Königin noch Stifte gesehen/)).not.toBeInTheDocument();
await answer("Stifte vorhanden", "Nein");
expect(
screen.getByText(/Weder Königin noch Stifte gesehen/),
).toBeInTheDocument();
});
it("shows a missing date next to the field and does not save", async () => {
renderPage();
await userEvent.clear(screen.getByLabelText(/^Datum/));
await userEvent.click(
screen.getByRole("button", { name: "Durchsicht speichern" }),
);
expect(screen.getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
expect(createInspection).not.toHaveBeenCalled();
});
it("labels only real form controls - groups are named, not label-for'd", () => {
const { container } = renderPage();
for (const label of container.querySelectorAll("label[for]")) {
const target = document.getElementById(label.getAttribute("for") ?? "");
expect(target, `label "${label.textContent}"`).not.toBeNull();
expect(["INPUT", "SELECT", "TEXTAREA", "BUTTON"]).toContain(target?.tagName);
}
});
});
@@ -2,10 +2,9 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, ClipboardCheck } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { createInspection } from "@/lib/inspections/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueInspection } from "@/lib/offline/db";
@@ -15,24 +14,19 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { LivingColonySelectField } from "@/components/colony-select-field";
import { FormSection } from "@/components/form/form-section";
import { RatingScale } from "@/components/form/rating-scale";
import { ToggleChip } from "@/components/form/toggle-chip";
import { ChoiceGroup } from "@/components/form/choice-group";
import { TriStateField } from "@/components/form/tri-state-field";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { cn } from "@/lib/utils";
import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
InspectionFormValues,
initialInspectionValues,
toInspectionInput,
@@ -43,18 +37,13 @@ export default function NewInspectionPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { data: colonies, isLoading: coloniesLoading } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const activeColonies =
colonies?.filter((colony) => colony.status === "ACTIVE") ?? [];
const [values, setValues] = useState<InspectionFormValues>(() =>
initialInspectionValues(searchParams.get("colonyId") ?? ""),
);
const [dirty, setDirty] = useState(false);
const [colonyError, setColonyError] = useState<string | null>(null);
const [dateError, setDateError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("inspection-new", values, dirty);
@@ -65,6 +54,7 @@ export default function NewInspectionPage() {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
if (key === "colonyId") setColonyError(null);
if (key === "date") setDateError(null);
}
// One key per opened form: a double tap or an offline retry of the
@@ -109,6 +99,11 @@ export default function NewInspectionPage() {
document.getElementById("inspection-colony")?.focus();
return;
}
if (!values.date) {
setDateError("Bitte ein Datum angeben.");
document.getElementById("inspection-date")?.focus();
return;
}
mutation.mutate(toInspectionInput(values));
}
@@ -120,7 +115,8 @@ export default function NewInspectionPage() {
}
}
const noQueenSigns = !values.queenSeen && !values.eggsPresent;
// Same rule as the backend automation: only an explicit "Nein" counts.
const noQueenSigns = values.queenSeen === false && values.eggsPresent === false;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
@@ -145,52 +141,21 @@ export default function NewInspectionPage() {
noValidate
>
<FormSection step={1} title="Welches Volk, wann?">
<Field
label="Volk"
required
<LivingColonySelectField
triggerId="inspection-colony"
value={values.colonyId}
onChange={(colonyId) => set("colonyId", colonyId)}
error={colonyError}
hint={
!coloniesLoading && activeColonies.length === 0
? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen."
: undefined
}
>
{({ describedBy, invalid }) => (
<Select
value={values.colonyId}
onValueChange={(value) => set("colonyId", value ?? "")}
>
<SelectTrigger
id="inspection-colony"
className="h-11 w-full"
aria-describedby={describedBy}
aria-invalid={invalid}
>
<SelectValue
placeholder={
coloniesLoading
? "Völker werden geladen…"
: "Volk wählen"
}
/>
</SelectTrigger>
<SelectContent>
{activeColonies.map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<Field label="Datum" required>
{({ id }) => (
/>
<Field id="inspection-date" label="Datum" required error={dateError}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.date}
onChange={(event) => set("date", event.target.value)}
/>
@@ -201,34 +166,17 @@ export default function NewInspectionPage() {
<FormSection
step={2}
title="Königin & Brut"
description="Antippen, was du gesehen hast."
description="Nur beantworten, was du nachgesehen hast – der Rest bleibt „Nicht geprüft“."
>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ToggleChip
label="Königin gesehen"
checked={values.queenSeen}
onChange={(checked) => set("queenSeen", checked)}
/>
<ToggleChip
label="Stifte vorhanden"
checked={values.eggsPresent}
onChange={(checked) => set("eggsPresent", checked)}
/>
<ToggleChip
label="Offene Brut"
checked={values.openBroodPresent}
onChange={(checked) => set("openBroodPresent", checked)}
/>
<ToggleChip
label="Verdeckelte Brut"
checked={values.cappedBroodPresent}
onChange={(checked) => set("cappedBroodPresent", checked)}
/>
<ToggleChip
label="Weiselzellen"
checked={values.queenCellsPresent}
onChange={(checked) => set("queenCellsPresent", checked)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{INSPECTION_OBSERVATIONS.map((observation) => (
<TriStateField
key={observation.key}
label={observation.label}
value={values[observation.key]}
onChange={(answer) => set(observation.key, answer)}
/>
))}
</div>
{noQueenSigns ? (
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
@@ -240,7 +188,7 @@ export default function NewInspectionPage() {
eine Kontrolle als Aufgabe vorgeschlagen.
</p>
) : null}
<Field label="Brutbild">
<Field label="Brutbild" group>
{({ id, labelId, describedBy }) => (
<RatingScale
id={id}
@@ -283,7 +231,7 @@ export default function NewInspectionPage() {
/>
)}
</Field>
<Field label="Sanftmut">
<Field label="Sanftmut" group>
{({ id, labelId }) => (
<RatingScale
id={id}
@@ -295,7 +243,7 @@ export default function NewInspectionPage() {
/>
)}
</Field>
<Field label="Wabensitz">
<Field label="Wabensitz" group>
{({ id, labelId }) => (
<RatingScale
id={id}
@@ -307,7 +255,7 @@ export default function NewInspectionPage() {
/>
)}
</Field>
<Field label="Schwarmstimmung">
<Field label="Schwarmstimmung" group>
{({ id, labelId }) => (
<RatingScale
id={id}
@@ -322,32 +270,18 @@ export default function NewInspectionPage() {
</FormSection>
<FormSection step={4} title="Futter">
<div
role="radiogroup"
aria-label="Futterstatus"
className="grid grid-cols-3 gap-2"
>
{FEED_STATUS_OPTIONS.map((option) => {
const selected = values.feedStatus === option.value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => set("feedStatus", option.value)}
className={cn(
"min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors",
selected
? "border-primary bg-primary font-medium text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option.label}
</button>
);
})}
</div>
<Field label="Futterstatus" group>
{({ id, labelId }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
)}
</Field>
</FormSection>
<FormSection
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Card, CardContent } from "@/components/ui/card";
import { StickySubmit } from "@/components/form/sticky-submit";
import AppLayout from "./layout";
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
usePathname: () => "/inspections/new",
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
user: {
id: "u1",
email: "[email protected]",
name: "Test Imker",
roleKey: "admin",
roleName: "Admin",
permissions: [],
mfaEnabled: false,
hasAvatar: false,
},
isLoading: false,
logout: vi.fn(),
}),
}));
vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null }));
vi.mock("@/components/offline-sync-badge", () => ({ OfflineSyncBadge: () => null }));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
// A box with overflow auto/scroll/hidden is a scroll container. Any such
// ancestor of the page content captures `position: sticky` (StickySubmit),
// and since the page itself scrolls on the window, the bar would never stick.
const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/;
function expectNoScrollContainerAbove(element: HTMLElement) {
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS);
}
}
describe("AppLayout", () => {
it("does not wrap the page content in its own scroll container", () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expectNoScrollContainerAbove(screen.getByText("Seiteninhalt"));
});
it("keeps every ancestor of a form's save bar, including the Card, free of scroll containers", () => {
render(
<AppLayout>
<main>
<Card className="max-w-lg">
<CardContent>
<form>
<StickySubmit>
<button type="submit">Speichern</button>
</StickySubmit>
</form>
</CardContent>
</Card>
</main>
</AppLayout>,
);
const saveBar = screen.getByRole("button", { name: "Speichern" }).parentElement!;
expectNoScrollContainerAbove(saveBar);
});
});
+5 -2
View File
@@ -36,8 +36,11 @@ export default function AppLayout({ children }: { children: ReactNode }) {
return null;
}
// The page scrolls on the window. No ancestor of the page content may be a
// scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick.
return (
<div className="flex min-h-screen">
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<Sidebar user={user} />
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
@@ -82,7 +85,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</DropdownMenu>
</div>
</header>
<div className="flex-1 overflow-y-auto pb-16 sm:pb-0">
<div className="flex-1 pb-(--bottom-nav-height) sm:pb-(--safe-area-bottom)">
<PageTransition>{children}</PageTransition>
</div>
</div>
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateProductionBatch } from "@/lib/production-batches/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { ProductionBatch } from "@/lib/production-batches/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,6 +18,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toApiDate } from "@/lib/dates";
/**
* Corrects a production batch entered with a typo. The article stays fixed:
@@ -73,12 +75,10 @@ function EditForm({
mutationFn: () =>
updateProductionBatch(batch.id, {
lotNumber: lotNumber.trim(),
date: new Date(date).toISOString(),
date: toApiDate(date),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate
? new Date(bestBeforeDate).toISOString()
: null,
notes: notes.trim(),
bestBeforeDate: emptyToNull(bestBeforeDate, toApiDate),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["production-batches"] });
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewProductionBatchPage from "./page";
import { fetchRawMaterialLots } from "@/lib/raw-materials/api";
import { fetchProducts } from "@/lib/products/api";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/lib/raw-materials/api", () => ({ fetchRawMaterialLots: vi.fn() }));
vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() }));
vi.mock("@/lib/production-batches/api", () => ({ createProductionBatch: vi.fn() }));
const LOT = {
id: "lot-1",
kind: "BEESWAX",
lotNumber: "W-01",
date: "2026-06-01T00:00:00.000Z",
quantity: 5000,
apiaryId: null,
harvestId: null,
notes: null,
createdAt: "2026-06-01T00:00:00.000Z",
remainingCount: 5000,
apiary: null,
harvest: null,
usedIn: [],
};
function gramsInputs() {
return screen.getAllByLabelText("Gramm") as HTMLInputElement[];
}
describe("NewProductionBatchPage – consumed raw materials", () => {
beforeEach(() => {
vi.mocked(fetchRawMaterialLots).mockResolvedValue([LOT] as never);
vi.mocked(fetchProducts).mockResolvedValue([]);
});
it("removing a middle row keeps the other rows with their own fields and values", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<NewProductionBatchPage />
</QueryClientProvider>,
);
const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" });
// Enabled once the raw material lots have loaded.
await vi.waitFor(() => expect(addRow).toBeEnabled());
for (let row = 0; row < 3; row += 1) {
await userEvent.click(addRow);
}
const [first, middle, last] = gramsInputs();
await userEvent.type(first, "100");
await userEvent.type(middle, "200");
await userEvent.type(last, "300");
await userEvent.click(screen.getByRole("button", { name: "Rohstoff 2 entfernen" }));
expect(gramsInputs().map((input) => input.value)).toEqual(["100", "300"]);
// The third row's own input survives - not the middle one refilled.
expect(last).toBeInTheDocument();
expect(middle).not.toBeInTheDocument();
});
});
@@ -1,6 +1,6 @@
"use client";
import { FormEvent, useState } from "react";
import { FormEvent, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -18,16 +18,15 @@ 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 { RecordSelect } from "@/components/form/record-select";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
function today(): string {
return new Date().toISOString().slice(0, 10);
interface InputRow {
/** Stable client-side identity of the row (React key, field ids). */
key: string;
rawMaterialLotId: string;
grams: string;
}
export default function NewProductionBatchPage() {
@@ -35,26 +34,31 @@ export default function NewProductionBatchPage() {
const queryClient = useQueryClient();
const [productId, setProductId] = useState("");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(today());
const [date, setDate] = useState(todayLocalIsoDate());
const [quantity, setQuantity] = useState("");
const [bestBeforeDate, setBestBeforeDate] = useState("");
const [notes, setNotes] = useState("");
// Verbrauchte Rohstoffe: je Zeile eine Rohstoff-Charge und Gramm.
const [inputs, setInputs] = useState<
{ rawMaterialLotId: string; grams: string }[]
>([]);
// Verbrauchte Rohstoffe: je Zeile eine Rohstoff-Charge und Gramm. Each row
// carries its own key, so removing one keeps the others' fields (and their
// select/focus state) attached to the right row - an index would shift them.
const [inputs, setInputs] = useState<InputRow[]>([]);
const nextRowKey = useRef(0);
const { data: rawLots } = useQuery({
queryKey: ["raw-material-lots"],
queryFn: () => fetchRawMaterialLots(),
});
const availableLots = (rawLots ?? []).filter((lot) => lot.remainingCount > 0);
const updateInput = (
index: number,
change: Partial<{ rawMaterialLotId: string; grams: string }>,
) =>
const updateInput = (key: string, change: Partial<Omit<InputRow, "key">>) =>
setInputs((current) =>
current.map((row, i) => (i === index ? { ...row, ...change } : row)),
current.map((row) => (row.key === key ? { ...row, ...change } : row)),
);
const addInput = () => {
const key = `row-${nextRowKey.current}`;
nextRowKey.current += 1;
setInputs((current) => [...current, { key, rawMaterialLotId: "", grams: "" }]);
};
const removeInput = (key: string) =>
setInputs((current) => current.filter((row) => row.key !== key));
const [error, setError] = useState<string | null>(null);
const { data: products } = useQuery({
@@ -64,19 +68,17 @@ export default function NewProductionBatchPage() {
const manufactured = (products ?? []).filter(
(product) => product.kind === "MANUFACTURED",
);
const selected = manufactured.find((product) => product.id === productId);
const mutation = useMutation({
mutationFn: () =>
createProductionBatch({
productId,
lotNumber: lotNumber.trim() || undefined,
date: new Date(date).toISOString(),
// Empty: the server generates the number (automation format).
lotNumber: emptyToNull(lotNumber),
date: toApiDate(date),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate
? new Date(bestBeforeDate).toISOString()
: undefined,
notes: notes.trim() || undefined,
bestBeforeDate: emptyToNull(bestBeforeDate, toApiDate),
notes: emptyToNull(notes),
inputs: inputs
.filter((row) => row.rawMaterialLotId && Number(row.grams) > 0)
.map((row) => ({
@@ -122,23 +124,14 @@ export default function NewProductionBatchPage() {
>
<div className="flex flex-col gap-2">
<Label htmlFor="pb-product">Artikel</Label>
<Select
<RecordSelect
id="pb-product"
value={productId}
onValueChange={(value) => setProductId(value ?? "")}
>
<SelectTrigger id="pb-product" className="w-full">
<SelectValue placeholder="Artikel wählen">
{selected?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{manufactured.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setProductId}
items={products ? manufactured : undefined}
getLabel={(product) => product.name}
placeholder="Artikel wählen"
/>
{products && manufactured.length === 0 ? (
<p className="text-xs text-muted-foreground">
Noch kein Artikel der Art &bdquo;Hergestellte Ware&ldquo;.
@@ -203,40 +196,24 @@ export default function NewProductionBatchPage() {
(candidate) => candidate.id === row.rawMaterialLotId,
);
return (
<div key={index} className="flex flex-wrap items-end gap-2">
<div key={row.key} className="flex flex-wrap items-end gap-2">
<div className="flex min-w-48 flex-1 flex-col gap-1">
<Label htmlFor={`pb-input-lot-${index}`}>Rohstoff</Label>
<Select
<Label htmlFor={`pb-input-lot-${row.key}`}>Rohstoff</Label>
<RecordSelect
id={`pb-input-lot-${row.key}`}
value={row.rawMaterialLotId}
onValueChange={(value) =>
updateInput(index, { rawMaterialLotId: value ?? "" })
onChange={(value) => updateInput(row.key, { rawMaterialLotId: value })}
items={rawLots ? availableLots : undefined}
getLabel={(candidate) =>
`${RAW_MATERIAL_KIND_LABELS[candidate.kind]} ${candidate.lotNumber} (noch ${formatGrams(candidate.remainingCount)})`
}
>
<SelectTrigger
id={`pb-input-lot-${index}`}
className="w-full"
>
<SelectValue placeholder="Charge wählen">
{lot
? `${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{availableLots.map((candidate) => (
<SelectItem key={candidate.id} value={candidate.id}>
{RAW_MATERIAL_KIND_LABELS[candidate.kind]}{" "}
{candidate.lotNumber} (noch{" "}
{formatGrams(candidate.remainingCount)})
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="Charge wählen"
/>
</div>
<div className="flex w-28 flex-col gap-1">
<Label htmlFor={`pb-input-grams-${index}`}>Gramm</Label>
<Label htmlFor={`pb-input-grams-${row.key}`}>Gramm</Label>
<Input
id={`pb-input-grams-${index}`}
id={`pb-input-grams-${row.key}`}
type="number"
inputMode="numeric"
min={1}
@@ -244,7 +221,7 @@ export default function NewProductionBatchPage() {
step={1}
value={row.grams}
onChange={(event) =>
updateInput(index, { grams: event.target.value })
updateInput(row.key, { grams: event.target.value })
}
/>
</div>
@@ -252,11 +229,8 @@ export default function NewProductionBatchPage() {
type="button"
variant="outline"
size="sm"
onClick={() =>
setInputs((current) =>
current.filter((_, i) => i !== index),
)
}
aria-label={`Rohstoff ${index + 1} entfernen`}
onClick={() => removeInput(row.key)}
>
Entfernen
</Button>
@@ -269,12 +243,7 @@ export default function NewProductionBatchPage() {
size="sm"
className="self-start"
disabled={availableLots.length === 0}
onClick={() =>
setInputs((current) => [
...current,
{ rawMaterialLotId: "", grams: "" },
])
}
onClick={addInput}
>
+ Rohstoff hinzufügen
</Button>
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateProduct } from "@/lib/products/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Product, ProductStatus, ShopCategory } from "@/lib/products/types";
import {
PRODUCT_KIND_LABELS,
@@ -15,13 +16,7 @@ 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 { EnumSelect } from "@/components/form/enum-select";
import {
Dialog,
DialogContent,
@@ -47,11 +42,11 @@ export function EditProductDialog({ product }: { product: Product }) {
mutationFn: () =>
updateProduct(product.id, {
name,
description: description || undefined,
sku: sku || undefined,
packagingSize: packagingSize ? Number(packagingSize) : undefined,
weight: weight ? Number(weight) : undefined,
salesUnit: salesUnit || undefined,
description: emptyToNull(description),
sku: emptyToNull(sku),
packagingSize: emptyToNull(packagingSize, Number),
weight: emptyToNull(weight, Number),
salesUnit: emptyToNull(salesUnit),
status,
shopCategory,
}),
@@ -131,45 +126,21 @@ export function EditProductDialog({ product }: { product: Product }) {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-status">Status</Label>
<Select
<EnumSelect
id="product-edit-status"
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>
onChange={setStatus}
labels={PRODUCT_STATUS_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-shop-category">Kategorie im Webshop</Label>
<Select
<EnumSelect
id="product-edit-shop-category"
value={shopCategory}
onValueChange={(value) =>
setShopCategory((value ?? shopCategory) as ShopCategory)
}
>
<SelectTrigger id="product-edit-shop-category" className="w-full">
<SelectValue>{SHOP_CATEGORY_LABELS[shopCategory]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
<SelectItem key={option} value={option}>
{SHOP_CATEGORY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setShopCategory}
labels={SHOP_CATEGORY_LABELS}
/>
<p className="text-xs text-muted-foreground">
Art: {PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.
</p>
@@ -54,11 +54,11 @@ describe("NewProductPage", () => {
expect(vi.mocked(createProduct).mock.calls[0][0]).toEqual({
name: "Frühtracht",
description: undefined,
description: null,
sku: "FT-500",
packagingSize: 500,
weight: undefined,
salesUnit: undefined,
weight: null,
salesUnit: null,
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
});
@@ -17,15 +17,8 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { PRODUCT_KIND_LABELS, SHOP_CATEGORY_LABELS } from "@/lib/products/labels";
import type { ProductKind, ShopCategory } from "@/lib/products/types";
import {
ProductFormErrors,
ProductFormValues,
@@ -135,44 +128,24 @@ export default function NewProductPage() {
hint="Honig wird abgefüllt; Kerzen, Tinkturen und Salben bekommen Herstellungschargen"
>
{({ id, describedBy }) => (
<Select
<EnumSelect
id={id}
describedBy={describedBy}
value={values.kind}
onValueChange={(value) =>
set("kind", (value ?? values.kind) as ProductKind)
}
>
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
<SelectValue>{PRODUCT_KIND_LABELS[values.kind]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(PRODUCT_KIND_LABELS) as ProductKind[]).map((option) => (
<SelectItem key={option} value={option}>
{PRODUCT_KIND_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(kind) => set("kind", kind)}
labels={PRODUCT_KIND_LABELS}
/>
)}
</Field>
<Field label="Kategorie im Webshop">
{({ id, describedBy }) => (
<Select
<EnumSelect
id={id}
describedBy={describedBy}
value={values.shopCategory}
onValueChange={(value) =>
set("shopCategory", (value ?? values.shopCategory) as ShopCategory)
}
>
<SelectTrigger id={id} aria-describedby={describedBy} className="w-full">
<SelectValue>{SHOP_CATEGORY_LABELS[values.shopCategory]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(SHOP_CATEGORY_LABELS) as ShopCategory[]).map((option) => (
<SelectItem key={option} value={option}>
{SHOP_CATEGORY_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(shopCategory) => set("shopCategory", shopCategory)}
labels={SHOP_CATEGORY_LABELS}
/>
)}
</Field>
<Field label="Verkaufseinheit" hint="z. B. Glas, Flasche">
@@ -3,6 +3,7 @@ import type {
ProductKind,
ShopCategory,
} from "@/lib/products/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ProductFormValues {
@@ -38,19 +39,14 @@ export function validateProduct(values: ProductFormValues): ProductFormErrors {
return errors;
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
const numberOrUndefined = (value: string) =>
value.trim() ? Number(value) : undefined;
export function toProductInput(values: ProductFormValues): CreateProductInput {
return {
name: values.name.trim(),
description: textOrUndefined(values.description),
sku: textOrUndefined(values.sku),
packagingSize: numberOrUndefined(values.packagingSize),
weight: numberOrUndefined(values.weight),
salesUnit: textOrUndefined(values.salesUnit),
description: emptyToNull(values.description),
sku: emptyToNull(values.sku),
packagingSize: emptyToNull(values.packagingSize, Number),
weight: emptyToNull(values.weight, Number),
salesUnit: emptyToNull(values.salesUnit),
kind: values.kind,
shopCategory: values.shopCategory,
};
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AssignColonyDialog } from "./assign-colony-dialog";
import { fetchColonies } from "@/lib/colonies/api";
import { assignQueenColony } from "@/lib/queens/api";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/queens/api", () => ({ assignQueenColony: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<AssignColonyDialog queenId="queen-1" queenStatus="ACTIVE" />
</QueryClientProvider>,
);
}
describe("AssignColonyDialog", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
{ id: "colony-2", code: "V-02", status: "QUEENLESS" },
{ id: "colony-3", code: "V-03", status: "MERGED" },
] as never);
vi.mocked(assignQueenColony).mockReset();
vi.mocked(assignQueenColony).mockResolvedValue({} as never);
});
it("offers the queenless colony - the one that needs a queen most", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Volk zuweisen" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("combobox"));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["V-01", "V-02 (Weisellos)"]);
await userEvent.click(screen.getByRole("option", { name: "V-02 (Weisellos)" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Zuweisen" }));
expect(assignQueenColony).toHaveBeenCalledWith("queen-1", { colonyId: "colony-2" });
});
});
@@ -9,7 +9,7 @@ import { QUEEN_UNAVAILABLE_STATUSES } from "@/lib/queens/labels";
import { QueenStatus } from "@/lib/queens/types";
import { fetchColonies } from "@/lib/colonies/api";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { LivingColonySelectField } from "@/components/colony-select-field";
import {
Dialog,
DialogContent,
@@ -18,13 +18,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function AssignColonyDialog({
queenId,
@@ -72,23 +65,11 @@ export function AssignColonyDialog({
mutation.mutate();
}}
>
<div className="flex flex-col gap-2">
<Label htmlFor="assign-colony">Volk</Label>
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
<SelectTrigger id="assign-colony" className="h-11 w-full">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
{(colonies ?? [])
.filter((colony) => colony.status === "ACTIVE")
.map((colony) => (
<SelectItem key={colony.id} value={colony.id}>
{colony.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<LivingColonySelectField
triggerId="assign-colony"
value={colonyId}
onChange={setColonyId}
/>
<DialogFooter>
<Button type="submit" size="touch" disabled={!colonyId || mutation.isPending}>
{mutation.isPending ? "Wird zugewiesen…" : "Zuweisen"}
@@ -7,19 +7,15 @@ import { fetchQueens, updateQueen } from "@/lib/queens/api";
import { suggestMarkingColorForYear } from "@/lib/queens/marking-color";
import { Queen, QueenStatus } from "@/lib/queens/types";
import { QUEEN_STATUSES, QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import {
Card,
CardContent,
@@ -57,24 +53,24 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
queryKey: ["queens"],
queryFn: () => fetchQueens(),
});
const motherOptions = queens?.filter((option) => option.id !== queen.id) ?? [];
const motherOptions = queens?.filter((option) => option.id !== queen.id);
const mutation = useMutation({
mutationFn: () =>
updateQueen(queen.id, {
hatchYear: hatchYear ? Number(hatchYear) : undefined,
breed: breed || undefined,
origin: origin || undefined,
breeder: breeder || undefined,
markingColor: markingColor || undefined,
markingNumber: markingNumber || undefined,
matingType: matingType || undefined,
matingStatus: matingStatus || undefined,
inseminationData: inseminationData || undefined,
motherLine: motherLine || undefined,
fatherLine: fatherLine || undefined,
motherQueenId: motherQueenId || undefined,
notes: notes || undefined,
hatchYear: emptyToNull(hatchYear, Number),
breed: emptyToNull(breed),
origin: emptyToNull(origin),
breeder: emptyToNull(breeder),
markingColor: emptyToNull(markingColor),
markingNumber: emptyToNull(markingNumber),
matingType: emptyToNull(matingType),
matingStatus: emptyToNull(matingStatus),
inseminationData: emptyToNull(inseminationData),
motherLine: emptyToNull(motherLine),
fatherLine: emptyToNull(fatherLine),
motherQueenId: emptyToNull(motherQueenId),
notes: emptyToNull(notes),
status,
}),
onSuccess: () => {
@@ -105,18 +101,13 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
<FormSection step={1} title="Status & Zeichnung">
<Field label="Status" required>
{({ id }) => (
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{QUEEN_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{QUEEN_STATUSES.map((option) => (
<SelectItem key={option} value={option}>
{QUEEN_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
id={id}
value={status}
onChange={setStatus}
values={QUEEN_STATUSES}
labels={QUEEN_STATUS_LABELS}
/>
)}
</Field>
<Field label="Schlupfjahr" hint="Die Zeichenfarbe wird passend vorgeschlagen.">
@@ -195,28 +186,14 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
</Field>
<Field label="Mutter">
{({ id }) => (
<Select
value={motherQueenId || "NONE"}
onValueChange={(value) =>
setMotherQueenId(value === "NONE" ? "" : (value ?? ""))
}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Keine Mutter hinterlegt">
{motherQueenId
? motherOptions.find((option) => option.id === motherQueenId)?.code
: "Keine Mutter hinterlegt"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Keine Mutter hinterlegt</SelectItem>
{motherOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.code}
</SelectItem>
))}
</SelectContent>
</Select>
<RecordSelect
id={id}
value={motherQueenId}
onChange={setMotherQueenId}
items={motherOptions}
getLabel={(option) => option.code}
noneLabel="Keine Mutter hinterlegt"
/>
)}
</Field>
<Field label="Mutterlinie">
@@ -10,18 +10,14 @@ import {
updateQueenEvaluation,
} from "@/lib/queens/api";
import { CreateQueenEvaluationInput, QueenEvaluation } from "@/lib/queens/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { fetchColonies } from "@/lib/colonies/api";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Field } from "@/components/form/field";
@@ -39,6 +35,7 @@ import {
DialogSubmitBar,
EditTriggerButton,
} from "../../colonies/[id]/edit-dialog-parts";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
type CriterionKey = keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId" | "notes">;
type Scores = Record<CriterionKey, number | null>;
@@ -55,14 +52,6 @@ const CRITERIA: { key: CriterionKey; label: string }[] = [
const DEFAULT_SCORE = 3;
const RATING_REQUIRED_MESSAGE = "Bitte einen Wert von 1 bis 5 antippen.";
/** 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 {
return new Date().toISOString().slice(0, 10);
}
function defaultScores(): Scores {
return Object.fromEntries(CRITERIA.map(({ key }) => [key, DEFAULT_SCORE])) as Scores;
}
@@ -122,6 +111,7 @@ function EvaluationFields({
key={criterion.key}
label={criterion.label}
required
group
error={missing.includes(criterion.key) ? RATING_REQUIRED_MESSAGE : null}
>
{({ id, labelId, describedBy }) => (
@@ -194,7 +184,8 @@ function EditEvaluationDialog({
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const form = useEvaluationForm(evaluation.date.slice(0, 10), scoresFrom(evaluation));
const [colonyId, setColonyId] = useState(evaluation.colonyId ?? NO_COLONY);
// "" = "Kein Volk": a rating may stand on its own.
const [colonyId, setColonyId] = useState(evaluation.colonyId ?? "");
const [notes, setNotes] = useState(evaluation.notes ?? "");
const [error, setError] = useState<string | null>(null);
@@ -206,10 +197,10 @@ function EditEvaluationDialog({
const mutation = useMutation({
mutationFn: () =>
updateQueenEvaluation(evaluation.id, {
date: new Date(form.date).toISOString(),
date: toApiDate(form.date),
...toScoreInput(form.scores),
colonyId: colonyId === NO_COLONY ? undefined : colonyId,
notes: notes || undefined,
colonyId: emptyToNull(colonyId),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
@@ -241,19 +232,14 @@ function EditEvaluationDialog({
<EvaluationFields {...form.fieldProps} />
<Field label="Volk">
{({ id }) => (
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? NO_COLONY)}>
<SelectTrigger id={id} className="h-11 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>
<RecordSelect
id={id}
value={colonyId}
onChange={setColonyId}
items={colonies}
getLabel={colonyOptionLabel}
noneLabel="Kein Volk"
/>
)}
</Field>
<Field label="Notizen">
@@ -271,7 +257,7 @@ function EditEvaluationDialog({
function AddEvaluationDialog({ queenId }: { queenId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const form = useEvaluationForm(todayIsoDate(), defaultScores());
const form = useEvaluationForm(todayLocalIsoDate(), defaultScores());
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
@@ -292,7 +278,7 @@ function AddEvaluationDialog({ queenId }: { queenId: string }) {
setError(null);
if (!form.validate()) return;
mutation.mutate({
date: new Date(form.date).toISOString(),
date: toApiDate(form.date),
...toScoreInput(form.scores),
});
}
@@ -60,7 +60,7 @@ describe("NewQueenPage", () => {
hatchYear: 2026,
markingColor: "Weiß",
});
expect(input.motherQueenId).toBeUndefined();
expect(input.motherQueenId).toBeNull();
expect(push).toHaveBeenCalledWith("/queens/q-1");
});
@@ -8,19 +8,13 @@ import { Crown } from "lucide-react";
import { createQueen, fetchQueens } from "@/lib/queens/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
import { Field } from "@/components/form/field";
import { ChoiceGroup } from "@/components/form/choice-group";
import { FormSection } from "@/components/form/form-section";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
@@ -37,6 +31,10 @@ type FieldErrors = Partial<Record<RequiredKey, string>>;
const REQUIRED_KEYS: RequiredKey[] = ["code"];
const MIN_HATCH_YEAR = 2000;
const MARKING_COLOR_CHOICES = MARKING_COLOR_OPTIONS.map((color) => ({
value: color,
label: color,
}));
function validate(values: QueenFormValues): FieldErrors {
const errors: FieldErrors = {};
@@ -180,39 +178,20 @@ export default function NewQueenPage() {
>
<Field
label="Zeichenfarbe"
group
hint="Wird nach internationaler Konvention aus dem Schlupfjahr vorgeschlagen (letzte Ziffer: 1/6 Weiß, 2/7 Gelb, 3/8 Rot, 4/9 Grün, 5/0 Blau), kann aber angepasst werden. Nochmal tippen hebt die Auswahl auf."
>
{({ id, labelId, describedBy }) => (
<div
<ChoiceGroup
id={id}
role="radiogroup"
aria-labelledby={labelId}
aria-describedby={describedBy}
className="grid grid-cols-3 gap-2 sm:grid-cols-5"
>
{MARKING_COLOR_OPTIONS.map((option) => {
const selected = values.markingColor === option;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={selected}
onClick={() =>
set("markingColor", selected ? "" : option)
}
className={cn(
"min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors",
selected
? "border-primary bg-primary font-medium text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{option}
</button>
);
})}
</div>
labelledBy={labelId}
describedBy={describedBy}
options={MARKING_COLOR_CHOICES}
value={values.markingColor}
clearValue={""}
onChange={(next) => set("markingColor", next)}
className="grid-cols-3 sm:grid-cols-5"
/>
)}
</Field>
<Field
@@ -268,35 +247,14 @@ export default function NewQueenPage() {
</Field>
<Field label="Mutter">
{({ id }) => (
<Select
value={values.motherQueenId || "NONE"}
onValueChange={(value) =>
set(
"motherQueenId",
value === "NONE" ? "" : (value ?? ""),
)
}
>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Keine Mutter hinterlegt">
{values.motherQueenId
? queens?.find(
(option) => option.id === values.motherQueenId,
)?.code
: "Keine Mutter hinterlegt"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">
Keine Mutter hinterlegt
</SelectItem>
{queens?.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.code}
</SelectItem>
))}
</SelectContent>
</Select>
<RecordSelect
id={id}
value={values.motherQueenId}
onChange={(value) => set("motherQueenId", value)}
items={queens}
getLabel={(queen) => queen.code}
noneLabel="Keine Mutter hinterlegt"
/>
)}
</Field>
</FormSection>
@@ -1,5 +1,6 @@
import type { createQueen } from "@/lib/queens/api";
import { suggestMarkingColorForYear } from "@/lib/queens/marking-color";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Everything the form holds, as plain values that survive a draft save. */
export interface QueenFormValues {
@@ -53,19 +54,16 @@ export function withHatchYear(
};
}
const textOrUndefined = (value: string) =>
value.trim() ? value.trim() : undefined;
export function toQueenInput(
values: QueenFormValues,
): Parameters<typeof createQueen>[0] {
return {
code: values.code.trim(),
hatchYear: values.hatchYear.trim() ? Number(values.hatchYear) : undefined,
breed: textOrUndefined(values.breed),
origin: textOrUndefined(values.origin),
markingColor: textOrUndefined(values.markingColor),
markingNumber: textOrUndefined(values.markingNumber),
motherQueenId: values.motherQueenId || undefined,
hatchYear: emptyToNull(values.hatchYear, Number),
breed: emptyToNull(values.breed),
origin: emptyToNull(values.origin),
markingColor: emptyToNull(values.markingColor),
markingNumber: emptyToNull(values.markingNumber),
motherQueenId: emptyToNull(values.motherQueenId),
};
}
+10 -25
View File
@@ -14,13 +14,7 @@ import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
export default function QueensPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active");
@@ -61,24 +55,15 @@ export default function QueensPage() {
activeCount={activeQueens.length}
historyCount={historyQueens.length}
/>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))}
>
<SelectTrigger className="w-40" aria-label="Nach Status filtern">
<SelectValue placeholder="Status">
{status ? QUEEN_STATUS_LABELS[status] : "Alle Status"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{QUEEN_STATUSES.map((option) => (
<SelectItem key={option} value={option}>
{QUEEN_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
ariaLabel="Nach Status filtern"
className="w-40"
value={status}
onChange={setStatus}
values={QUEEN_STATUSES}
labels={QUEEN_STATUS_LABELS}
noneLabel="Alle Status"
/>
</div>
<QueryState query={queensQuery} what="Königinnen">
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QuickAddMenu } from "./quick-add-menu";
let mockPathname = "/dashboard";
vi.mock("next/navigation", () => ({
usePathname: () => mockPathname,
}));
describe("QuickAddMenu", () => {
beforeEach(() => {
mockPathname = "/dashboard";
});
it.each(["/dashboard", "/colonies", "/colonies/abc", "/news", "/tasks/renewal"])(
"offers the quick-add button on %s",
(pathname) => {
mockPathname = pathname;
render(<QuickAddMenu />);
expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument();
},
);
it.each([
"/inspections/new",
"/settings/label-templates/new",
"/colonies/abc/edit",
"/colonies/new/",
])("hides the quick-add button on the form route %s so it cannot cover the save bar", (pathname) => {
mockPathname = pathname;
render(<QuickAddMenu />);
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -20,14 +21,29 @@ const QUICK_ACTIONS = [
{ label: "Ernte", href: "/harvests/new" },
];
// Create and edit forms end in a save bar (StickySubmit) right where this
// button floats. Deciding by route rather than letting StickySubmit register
// itself in a context is known on the first render (no flash, no layout
// shift), needs no client state in StickySubmit and also covers form routes
// without a sticky bar. Mid-form, jumping to another new form is no quick
// action anyway.
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
export function QuickAddMenu() {
const pathname = usePathname();
if (FORM_ROUTE.test(pathname)) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-lg"
className="fixed right-4 bottom-20 z-40 rounded-full shadow-lg sm:hidden"
// size-12: 48px touch target for gloved hands (icon-lg is 36px).
className="fixed right-4 bottom-[calc(var(--bottom-nav-height)+--spacing(4))] z-40 size-12 rounded-full shadow-lg sm:hidden"
aria-label="Neu"
/>
}
@@ -15,24 +15,20 @@ 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";
const NO_APIARY = "__none__";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
export default function NewRawMaterialPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [kind, setKind] = useState<RawMaterialKind>("BEESWAX");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [date, setDate] = useState(todayLocalIsoDate);
const [quantity, setQuantity] = useState("");
const [apiaryId, setApiaryId] = useState(NO_APIARY);
// "" = "Kein Stand".
const [apiaryId, setApiaryId] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -40,17 +36,17 @@ export default function NewRawMaterialPage() {
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const selectedApiary = apiaries?.find((apiary) => apiary.id === apiaryId);
const mutation = useMutation({
mutationFn: () =>
createRawMaterialLot({
kind,
lotNumber: lotNumber.trim() || undefined,
date: new Date(date).toISOString(),
// Empty: the server generates the number (automation format).
lotNumber: emptyToNull(lotNumber),
date: toApiDate(date),
quantity: Number(quantity),
apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId,
notes: notes.trim() || undefined,
apiaryId: emptyToNull(apiaryId),
notes: emptyToNull(notes),
}),
onSuccess: (lot) => {
void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] });
@@ -86,25 +82,12 @@ export default function NewRawMaterialPage() {
>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-kind">Art</Label>
<Select
<EnumSelect
id="raw-kind"
value={kind}
onValueChange={(value) =>
setKind((value ?? kind) as RawMaterialKind)
}
>
<SelectTrigger id="raw-kind" className="w-full">
<SelectValue>{RAW_MATERIAL_KIND_LABELS[kind]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
Object.keys(RAW_MATERIAL_KIND_LABELS) as RawMaterialKind[]
).map((option) => (
<SelectItem key={option} value={option}>
{RAW_MATERIAL_KIND_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setKind}
labels={RAW_MATERIAL_KIND_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-lot">Chargennummer</Label>
@@ -143,24 +126,14 @@ export default function NewRawMaterialPage() {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-apiary">Stand (optional)</Label>
<Select
<RecordSelect
id="raw-apiary"
value={apiaryId}
onValueChange={(value) => setApiaryId(value ?? NO_APIARY)}
>
<SelectTrigger id="raw-apiary" className="w-full">
<SelectValue>
{selectedApiary?.name ?? "Kein Stand"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_APIARY}>Kein Stand</SelectItem>
{(apiaries ?? []).map((apiary) => (
<SelectItem key={apiary.id} value={apiary.id}>
{apiary.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setApiaryId}
items={apiaries}
getLabel={(apiary) => apiary.name}
noneLabel="Kein Stand"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="raw-notes">Notiz (optional, intern)</Label>
@@ -5,7 +5,8 @@ import { History } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchAuditLog } from "@/lib/audit-log/api";
import {
AUDIT_ENTITY_TYPE_OPTIONS,
AUDIT_ENTITY_TYPE_FILTER_VALUES,
AUDIT_ENTITY_TYPE_LABELS,
auditActionLabel,
auditEntityTypeLabel,
} from "@/lib/audit-log/labels";
@@ -15,15 +16,8 @@ import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
const ALL = "ALL";
export default function AuditLogPage() {
const [entityType, setEntityType] = useState("");
@@ -73,24 +67,14 @@ export default function AuditLogPage() {
<label className="text-sm font-medium" htmlFor="audit-entity-type">
Bereich
</label>
<Select
value={entityType || ALL}
onValueChange={(value) => setEntityType(value === ALL ? "" : (value ?? ""))}
>
<SelectTrigger id="audit-entity-type" className="w-full">
<SelectValue>
{entityType ? auditEntityTypeLabel(entityType) : "Alle Bereiche"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Alle Bereiche</SelectItem>
{AUDIT_ENTITY_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
id="audit-entity-type"
value={entityType}
onChange={setEntityType}
values={AUDIT_ENTITY_TYPE_FILTER_VALUES}
labels={AUDIT_ENTITY_TYPE_LABELS}
noneLabel="Alle Bereiche"
/>
</div>
<div className="flex w-full flex-col gap-1 sm:w-72">
<label className="text-sm font-medium" htmlFor="audit-entity-id">
@@ -8,7 +8,12 @@ import {
resetAutomationRuleSequence,
updateAutomationRule,
} from "@/lib/automation/api";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import {
AutomationRule,
AutomationTriggerType,
UpdateAutomationRuleInput,
} from "@/lib/automation/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { formatGeneratedNumber } from "@/lib/automation/number-format";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -139,7 +144,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
rule.sequences?.find((sequence) => sequence.scope === kind)?.value ?? 0;
const mutation = useMutation({
mutationFn: (input: { active?: boolean; config?: Record<string, unknown>; actionTemplate?: string }) =>
mutationFn: (input: UpdateAutomationRuleInput) =>
updateAutomationRule(rule.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
@@ -180,7 +185,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
}
config.minGrams = parsed;
}
mutation.mutate({ actionTemplate: actionTemplate || undefined, config });
mutation.mutate({ actionTemplate: emptyToNull(actionTemplate), config });
}
return (
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { CalendarSyncTab } from "./calendar-sync-tab";
import {
getCalendarSyncSettings,
getCalendarSyncStatus,
runCalendarSyncNow,
updateCalendarSyncSettings,
} from "@/lib/calendar-sync/api";
import { ApiError } from "@/lib/auth/api-client";
import { CalendarSyncStatus } from "@/lib/calendar-sync/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/calendar-sync/api", () => ({
getCalendarSyncSettings: vi.fn(),
getCalendarSyncStatus: vi.fn(),
runCalendarSyncNow: vi.fn(),
updateCalendarSyncSettings: vi.fn(),
}));
const idle: CalendarSyncStatus = {
configured: true,
enabled: true,
groupId: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
pendingCount: 2,
failedCount: 1,
syncedCount: 5,
running: false,
};
function renderTab() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<CalendarSyncTab />
</QueryClientProvider>,
);
}
// The server checks the group id (an Entra object id); its German message
// says what is wrong, so the tab shows it instead of a generic failure.
describe("CalendarSyncTab settings", () => {
it("shows the server's reason when the group id is rejected", async () => {
vi.mocked(getCalendarSyncSettings).mockResolvedValue({
id: "singleton",
enabled: true,
groupId: null,
syncTask: true,
syncInspection: true,
syncTreatment: true,
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
updatedAt: "2026-10-12T00:00:00.000Z",
});
vi.mocked(getCalendarSyncStatus).mockResolvedValue(idle);
const reason =
"Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)";
vi.mocked(updateCalendarSyncSettings).mockRejectedValue(
new ApiError(400, reason),
);
renderTab();
await userEvent.type(
await screen.findByLabelText("Kennung des Microsoft-365-Gruppenkalenders"),
"meine-gruppe",
);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(reason));
});
});
// "Jetzt synchronisieren" only starts the pass on the server (202); the tab
// shows it running and follows the status until it is done.
describe("CalendarSyncTab status", () => {
beforeEach(() => {
vi.mocked(getCalendarSyncSettings).mockReset();
vi.mocked(getCalendarSyncStatus).mockReset();
vi.mocked(runCalendarSyncNow).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(getCalendarSyncSettings).mockResolvedValue({
id: "singleton",
enabled: true,
groupId: idle.groupId,
syncTask: true,
syncInspection: true,
syncTreatment: true,
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
updatedAt: "2026-10-12T00:00:00.000Z",
});
});
it("starts a run and says it was started, not finished", async () => {
vi.mocked(getCalendarSyncStatus).mockResolvedValue(idle);
vi.mocked(runCalendarSyncNow).mockResolvedValue({ ...idle, running: true });
renderTab();
await userEvent.click(
await screen.findByRole("button", { name: "Jetzt synchronisieren" }),
);
expect(runCalendarSyncNow).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith("Synchronisation gestartet"),
);
});
it("shows a running pass and blocks a second start", async () => {
vi.mocked(getCalendarSyncStatus).mockResolvedValue({
...idle,
running: true,
});
renderTab();
const button = await screen.findByRole("button", {
name: "Wird synchronisiert…",
});
expect(button).toBeDisabled();
});
it("keeps refreshing the status while a pass runs", async () => {
vi.mocked(getCalendarSyncStatus)
.mockResolvedValueOnce({ ...idle, running: true })
.mockResolvedValue({ ...idle, pendingCount: 0, running: false });
renderTab();
await screen.findByRole("button", { name: "Wird synchronisiert…" });
await waitFor(
() =>
expect(
screen.getByRole("button", { name: "Jetzt synchronisieren" }),
).toBeEnabled(),
{ timeout: 4000 },
);
expect(getCalendarSyncStatus).toHaveBeenCalledTimes(2);
});
});
@@ -10,6 +10,7 @@ import {
updateCalendarSyncSettings,
} from "@/lib/calendar-sync/api";
import { CalendarSyncSettings } from "@/lib/calendar-sync/types";
import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -57,7 +58,9 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
void queryClient.invalidateQueries({ queryKey: ["calendar-sync-status"] });
toast.success("Einstellungen gespeichert");
},
onError: () => toast.error("Einstellungen konnten nicht gespeichert werden"),
// The server names the problem, e.g. a group id that is no Entra object id.
onError: (error) =>
toast.error(errorMessage(error, "Einstellungen konnten nicht gespeichert werden")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -124,24 +127,31 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
);
}
// While a pass runs on the server the status is refreshed at this pace, so
// the counts and the button follow it without a reload.
const RUNNING_REFRESH_MS = 2000;
function StatusSection() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ["calendar-sync-status"],
queryFn: getCalendarSyncStatus,
refetchInterval: (query) => (query.state.data?.running ? RUNNING_REFRESH_MS : false),
});
const runNowMutation = useMutation({
mutationFn: runCalendarSyncNow,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["calendar-sync-status"] });
toast.success("Synchronisation ausgeführt");
// The server only starts the pass; the status query follows it.
onSuccess: (status) => {
queryClient.setQueryData(["calendar-sync-status"], status);
toast.success("Synchronisation gestartet");
},
onError: () => toast.error("Synchronisation fehlgeschlagen"),
});
const status = statusQuery.data;
const canRunNow = Boolean(status?.configured && status.enabled && status.groupId);
const running = runNowMutation.isPending || Boolean(status?.running);
return (
<Card className="max-w-lg">
@@ -179,10 +189,10 @@ function StatusSection() {
<Button
type="button"
variant="outline"
disabled={!canRunNow || runNowMutation.isPending}
disabled={!canRunNow || running}
onClick={() => runNowMutation.mutate()}
>
{runNowMutation.isPending ? "Wird synchronisiert…" : "Jetzt synchronisieren"}
{running ? "Wird synchronisiert…" : "Jetzt synchronisieren"}
</Button>
</CardContent>
</Card>
@@ -8,13 +8,8 @@ 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 { EnumSelect } from "@/components/form/enum-select";
import { MONTH_LABELS, MONTHS, Month, RECURRENCE_TYPE_LABELS } from "@/lib/task-templates/labels";
import {
Dialog,
DialogContent,
@@ -22,27 +17,7 @@ import {
DialogHeader,
DialogTitle,
} 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",
];
import { emptyToNull } from "@/lib/forms/empty-to-null";
export function CreateTaskTemplateDialog() {
const queryClient = useQueryClient();
@@ -50,7 +25,7 @@ export function CreateTaskTemplateDialog() {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [recurrenceType, setRecurrenceType] = useState<TaskTemplateRecurrenceType>("MONTHLY");
const [recurrenceMonth, setRecurrenceMonth] = useState("1");
const [recurrenceMonth, setRecurrenceMonth] = useState<Month>("1");
const [recurrenceDayOfMonth, setRecurrenceDayOfMonth] = useState("1");
const [intervalDays, setIntervalDays] = useState("14");
const [error, setError] = useState<string | null>(null);
@@ -80,7 +55,7 @@ export function CreateTaskTemplateDialog() {
setError(null);
mutation.mutate({
title,
description: description || undefined,
description: emptyToNull(description),
recurrenceType,
recurrenceMonth: recurrenceType === "YEARLY" ? Number(recurrenceMonth) : undefined,
recurrenceDayOfMonth:
@@ -124,42 +99,23 @@ export function CreateTaskTemplateDialog() {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-recurrence">Wiederholung</Label>
<Select
<EnumSelect
id="template-recurrence"
value={recurrenceType}
onValueChange={(value) =>
setRecurrenceType((value ?? recurrenceType) as TaskTemplateRecurrenceType)
}
>
<SelectTrigger id="template-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>
onChange={setRecurrenceType}
labels={RECURRENCE_TYPE_LABELS}
/>
</div>
{recurrenceType === "YEARLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-month">Monat</Label>
<Select
<EnumSelect
id="template-month"
value={recurrenceMonth}
onValueChange={(value) => setRecurrenceMonth(value ?? recurrenceMonth)}
>
<SelectTrigger id="template-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>
onChange={setRecurrenceMonth}
values={MONTHS}
labels={MONTH_LABELS}
/>
</div>
) : null}
{recurrenceType === "YEARLY" || recurrenceType === "MONTHLY" ? (
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { updateTaskTemplate } from "@/lib/task-templates/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { TaskPriority } from "@/lib/tasks/types";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
@@ -12,13 +13,14 @@ 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 { EnumSelect } from "@/components/form/enum-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
MONTH_LABELS,
MONTHS,
RECURRENCE_TYPE_LABELS,
toMonth,
} from "@/lib/task-templates/labels";
import { RecordSelect } from "@/components/form/record-select";
import {
Dialog,
DialogContent,
@@ -28,31 +30,6 @@ import {
DialogTrigger,
} 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> = {
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);
@@ -61,9 +38,7 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
const [recurrenceType, setRecurrenceType] = useState<TaskTemplateRecurrenceType>(
template.recurrenceType,
);
const [recurrenceMonth, setRecurrenceMonth] = useState(
template.recurrenceMonth?.toString() ?? "1",
);
const [recurrenceMonth, setRecurrenceMonth] = useState(toMonth(template.recurrenceMonth));
const [recurrenceDayOfMonth, setRecurrenceDayOfMonth] = useState(
template.recurrenceDayOfMonth?.toString() ?? "1",
);
@@ -71,9 +46,8 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
const [priority, setPriority] = useState<TaskPriority>(
template.priority ?? "NORMAL",
);
const [assignedUserId, setAssignedUserId] = useState(
template.assignedUserId ?? NO_ASSIGNEE,
);
// "" = "Niemand".
const [assignedUserId, setAssignedUserId] = useState(template.assignedUserId ?? "");
const [active, setActive] = useState(template.active);
const [error, setError] = useState<string | null>(null);
@@ -89,7 +63,7 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
mutationFn: () =>
updateTaskTemplate(template.id, {
title,
description: description || undefined,
description: emptyToNull(description),
recurrenceType,
recurrenceMonth: recurrenceType === "YEARLY" ? Number(recurrenceMonth) : undefined,
recurrenceDayOfMonth:
@@ -98,8 +72,7 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
: undefined,
intervalDays: recurrenceType === "INTERVAL_DAYS" ? Number(intervalDays) : undefined,
priority,
assignedUserId:
assignedUserId === NO_ASSIGNEE ? undefined : assignedUserId,
assignedUserId: emptyToNull(assignedUserId),
active,
}),
onSuccess: () => {
@@ -149,44 +122,23 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-recurrence">Wiederholung</Label>
<Select
<EnumSelect
id="template-edit-recurrence"
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>
onChange={setRecurrenceType}
labels={RECURRENCE_TYPE_LABELS}
/>
</div>
{recurrenceType === "YEARLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-month">Monat</Label>
<Select
<EnumSelect
id="template-edit-month"
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>
onChange={setRecurrenceMonth}
values={MONTHS}
labels={MONTH_LABELS}
/>
</div>
) : null}
{recurrenceType === "YEARLY" || recurrenceType === "MONTHLY" ? (
@@ -220,43 +172,25 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-priority">Priorität</Label>
<Select
<EnumSelect
id="template-edit-priority"
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>
onChange={setPriority}
values={TASK_PRIORITIES}
labels={TASK_PRIORITY_LABELS}
/>
</div>
{users ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-edit-assignee">Zugewiesen an</Label>
<Select
<RecordSelect
id="template-edit-assignee"
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>
onChange={setAssignedUserId}
items={users}
getLabel={(user) => user.name}
noneLabel="Niemand"
/>
</div>
) : null}
<label
@@ -40,7 +40,8 @@ function isTab(value: string | null): value is Tab {
function AutomationPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const tab: Tab = isTab(searchParams.get("tab")) ? (searchParams.get("tab") as Tab) : "rules";
const requestedTab = searchParams.get("tab");
const tab: Tab = isTab(requestedTab) ? requestedTab : "rules";
function setTab(next: Tab) {
router.replace(`/settings/automation?tab=${next}`);
@@ -5,7 +5,8 @@ import { Repeat } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deactivateTaskTemplate, fetchTaskTemplates } from "@/lib/task-templates/api";
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { TaskTemplate } from "@/lib/task-templates/types";
import { RECURRENCE_TYPE_LABELS } from "@/lib/task-templates/labels";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { EmptyState } from "@/components/empty-state";
@@ -14,18 +15,12 @@ import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-
import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
import { EditTaskTemplateDialog } from "./edit-task-template-dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich",
MONTHLY: "Monatlich",
INTERVAL_DAYS: "Alle X Tage",
};
function describeRecurrence(template: TaskTemplate): string {
if (template.recurrenceType === "YEARLY") {
return `${RECURRENCE_LABELS.YEARLY} (Tag ${template.recurrenceDayOfMonth ?? "?"}.${template.recurrenceMonth ?? "?"}.)`;
return `${RECURRENCE_TYPE_LABELS.YEARLY} (Tag ${template.recurrenceDayOfMonth ?? "?"}.${template.recurrenceMonth ?? "?"}.)`;
}
if (template.recurrenceType === "MONTHLY") {
return `${RECURRENCE_LABELS.MONTHLY} (Tag ${template.recurrenceDayOfMonth ?? "?"})`;
return `${RECURRENCE_TYPE_LABELS.MONTHLY} (Tag ${template.recurrenceDayOfMonth ?? "?"})`;
}
return `Alle ${template.intervalDays ?? "?"} Tage`;
}
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import { toast } from "sonner";
import BackupPage from "./page";
import {
exportBackup,
getBackupSchedule,
getBackupSettings,
importBackup,
} from "@/lib/backup/api";
import type { BackupScheduleStatus } from "@/lib/backup/types";
vi.mock("@/lib/backup/api", () => ({
exportBackup: vi.fn(),
importBackup: vi.fn(),
getBackupSchedule: vi.fn(),
getBackupSettings: vi.fn(),
runBackupNow: vi.fn(),
updateBackupSettings: vi.fn(),
}));
vi.mock("@/lib/import/api", () => ({ importCsv: vi.fn() }));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { mfaEnabled: false } }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));
const MB = 1024 * 1024;
const LIMIT_WARNING = "Backup ist größer als das Import-Limit – Wiederherstellung würde scheitern";
function schedule(overrides: Partial<BackupScheduleStatus> = {}): BackupScheduleStatus {
return {
enabled: true,
path: "/data/backups",
cronSchedule: "0 3 * * *",
retentionCount: 14,
lastRunAt: "2026-10-12T03:00:00.000Z",
importLimitBytes: 50 * MB,
lastBackupSizeBytes: 10 * MB,
lastBackupExceedsImportLimit: false,
envOverrides: { storagePath: true, cronSchedule: true, retentionCount: true },
...overrides,
};
}
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("BackupPage", () => {
beforeEach(() => {
vi.mocked(getBackupSettings).mockResolvedValue({
id: "singleton",
storagePath: null,
cronSchedule: null,
retentionCount: null,
updatedAt: "2026-10-12T03:00:00.000Z",
});
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:backup");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it("warns prominently when the newest automatic backup is larger than the import limit", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(
schedule({ lastBackupSizeBytes: 60 * MB, lastBackupExceedsImportLimit: true }),
);
renderWithClient(<BackupPage />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(LIMIT_WARNING);
expect(alert).toHaveTextContent("60 MB");
expect(alert).toHaveTextContent("50 MB");
});
it("shows no warning while the newest backup fits the import limit", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
renderWithClient(<BackupPage />);
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
expect(screen.queryByText(LIMIT_WARNING)).not.toBeInTheDocument();
});
it("warns after a manual export that is larger than the import limit, but still downloads it", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule({ importLimitBytes: 10 }));
vi.mocked(exportBackup).mockResolvedValue({
blob: new Blob(["x".repeat(20)]),
filename: "apicore-backup-2026-10-12.json",
});
renderWithClient(<BackupPage />);
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
expect(screen.queryByText(LIMIT_WARNING)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Backup exportieren" }));
expect(await screen.findByRole("alert")).toHaveTextContent(LIMIT_WARNING);
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledWith(expect.stringContaining("Import-Limit"));
});
async function importFile() {
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
});
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
}
it("lists what an old, unencrypted backup did not restore and keeps that visible", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup).mockResolvedValue({
imported: { apiary: 1 },
skipped: ["role", "calendarSyncSettings", "backupSettings", "batchMovement"],
sealed: false,
});
renderWithClient(<BackupPage />);
await importFile();
const notice = await screen.findByRole("status");
for (const label of [
"Rollen",
"Kalender-Einstellungen",
"Backup-Einstellungen",
"Bewegungsbuch der Chargen",
"nicht überschrieben",
]) {
expect(notice).toHaveTextContent(label);
}
expect(toast.warning).toHaveBeenCalledWith(expect.stringContaining("Kalender-Einstellungen"));
});
it("shows no such notice after a sealed backup", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup).mockResolvedValue({
imported: { apiary: 1 },
skipped: [],
sealed: true,
});
renderWithClient(<BackupPage />);
await importFile();
await vi.waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(toast.warning).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,7 @@
"use client";
import { ChangeEvent, FormEvent, useState } from "react";
import { Database } from "lucide-react";
import { AlertTriangle, Database } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -15,6 +15,7 @@ import {
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth/auth-context";
import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
import { unsealedImportWarning } from "@/lib/backup/import-summary";
import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
import { Badge } from "@/components/ui/badge";
@@ -23,13 +24,7 @@ import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { ErrorState } from "@/components/query-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
@@ -49,6 +44,38 @@ function downloadBlob(blob: Blob, filename: string): void {
URL.revokeObjectURL(url);
}
function formatMegabytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toLocaleString("de-DE", { maximumFractionDigits: 1 })} MB`;
}
interface OversizedBackup {
sizeBytes: number;
limitBytes: number;
}
// A backup the import would refuse is worthless on the day it is needed -
// shown in the same alert style as ErrorState, not as a passing toast.
function ImportLimitWarning({ backup, what }: { backup: OversizedBackup; what: string }) {
return (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
<div className="flex flex-col gap-1">
<p className="font-medium">
Backup ist größer als das Import-Limit – Wiederherstellung würde scheitern
</p>
<p className="text-muted-foreground">
{what} ist {formatMegabytes(backup.sizeBytes)} groß, eingespielt werden höchstens{" "}
{formatMegabytes(backup.limitBytes)}. Vor einer Wiederherstellung{" "}
<code className="font-mono text-xs">BACKUP_MAX_IMPORT_BYTES</code> erhöhen.
</p>
</div>
</div>
);
}
function BackupSection() {
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
@@ -56,12 +83,27 @@ function BackupSection() {
const [mfaCode, setMfaCode] = useState("");
const needsMfa = Boolean(user?.mfaEnabled);
const proofComplete = password.length > 0 && (!needsMfa || /^\d{6}$/.test(mfaCode));
const [oversizedExport, setOversizedExport] = useState<OversizedBackup | null>(null);
// Kept on screen - the list of what was left out is too long for a toast alone.
const [importNotice, setImportNotice] = useState<string | null>(null);
// Shares the cache entry with ScheduleSection - only the import limit is used here.
const scheduleQuery = useQuery({
queryKey: ["backup-schedule"],
queryFn: getBackupSchedule,
});
const exportMutation = useMutation({
mutationFn: exportBackup,
onSuccess: ({ blob, filename }) => {
downloadBlob(blob, filename);
toast.success("Backup heruntergeladen");
const limitBytes = scheduleQuery.data?.importLimitBytes;
if (limitBytes !== undefined && blob.size > limitBytes) {
setOversizedExport({ sizeBytes: blob.size, limitBytes });
toast.warning("Backup ist größer als das Import-Limit");
} else {
setOversizedExport(null);
}
},
onError: () => toast.error("Backup konnte nicht exportiert werden"),
});
@@ -76,10 +118,10 @@ function BackupSection() {
onSuccess: (summary) => {
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
toast.success(`Backup eingespielt: ${total} Datensätze verarbeitet`);
if (summary.skipped.length > 0) {
toast.warning(
"Altes, unverschlüsseltes Backup: Benutzer, Rollen, Webhooks und Audit-Log wurden nicht übernommen.",
);
const notice = unsealedImportWarning(summary);
setImportNotice(notice);
if (notice) {
toast.warning(notice);
}
setFile(null);
setPassword("");
@@ -118,6 +160,9 @@ function BackupSection() {
>
{exportMutation.isPending ? "Wird exportiert…" : "Backup exportieren"}
</Button>
{oversizedExport ? (
<ImportLimitWarning backup={oversizedExport} what="Das exportierte Backup" />
) : null}
<div className="flex flex-col gap-2 border-t border-border/60 pt-4">
<Label htmlFor="backup-file">Backup-Datei einspielen</Label>
@@ -163,6 +208,14 @@ function BackupSection() {
isPending={importMutation.isPending}
onConfirm={() => importMutation.mutate()}
/>
{importNotice ? (
<p
role="status"
className="rounded-xl border border-warning/40 bg-warning/10 p-3 text-sm text-foreground"
>
{importNotice}
</p>
) : null}
</div>
</CardContent>
</Card>
@@ -312,6 +365,15 @@ function ScheduleSection() {
<CardTitle>Automatische Backups</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
{status?.lastBackupExceedsImportLimit && status.lastBackupSizeBytes !== null ? (
<ImportLimitWarning
backup={{
sizeBytes: status.lastBackupSizeBytes,
limitBytes: status.importLimitBytes,
}}
what="Das letzte automatische Backup"
/>
) : null}
{scheduleQuery.isLoading ? (
<p className="text-muted-foreground">Lädt…</p>
) : scheduleQuery.isError ? (
@@ -428,19 +490,12 @@ function CsvImportSection() {
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="import-entity">Datentyp</Label>
<Select
<EnumSelect
id="import-entity"
value={entity}
onValueChange={(value) => setEntity((value ?? "apiaries") as ImportEntity)}
>
<SelectTrigger id="import-entity">
<SelectValue>{ENTITY_LABELS[entity]}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="apiaries">Bienenstände</SelectItem>
<SelectItem value="colonies">Völker</SelectItem>
<SelectItem value="queens">Königinnen</SelectItem>
</SelectContent>
</Select>
onChange={setEntity}
labels={ENTITY_LABELS}
/>
<p className="text-muted-foreground">Spalten: {ENTITY_COLUMNS[entity]}</p>
</div>
<div className="flex flex-col gap-2">
@@ -7,18 +7,11 @@ import {
LabelTextAlign,
PLACEHOLDER_KEYS,
PLACEHOLDER_LABELS,
PlaceholderKey,
} from "@/lib/label-templates/types";
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 { EnumSelect } from "@/components/form/enum-select";
const FONT_FAMILY_LABELS: Record<LabelFontFamily, string> = {
sans: "Serifenlos",
@@ -137,28 +130,14 @@ function TextFields({
<>
<div className="flex flex-col gap-2">
<Label htmlFor="element-text-source">Inhalt</Label>
<Select
value={element.placeholderKey ?? "__literal"}
onValueChange={(value) =>
onChange({
placeholderKey: value === "__literal" ? null : (value as PlaceholderKey),
})
}
>
<SelectTrigger className="w-full" id="element-text-source">
<SelectValue>
{element.placeholderKey ? PLACEHOLDER_LABELS[element.placeholderKey] : "Fester Text"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__literal">Fester Text</SelectItem>
{PLACEHOLDER_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{PLACEHOLDER_LABELS[key]}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
id="element-text-source"
value={element.placeholderKey ?? ""}
onChange={(key) => onChange({ placeholderKey: key === "" ? null : key })}
values={PLACEHOLDER_KEYS}
labels={PLACEHOLDER_LABELS}
noneLabel="Fester Text"
/>
</div>
{!element.placeholderKey ? (
<div className="flex flex-col gap-2">
@@ -194,21 +173,12 @@ function TextFields({
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="element-text-font">Schriftart</Label>
<Select
<EnumSelect
id="element-text-font"
value={element.fontFamily}
onValueChange={(value) => onChange({ fontFamily: value as LabelFontFamily })}
>
<SelectTrigger className="w-full" id="element-text-font">
<SelectValue>{FONT_FAMILY_LABELS[element.fontFamily]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(FONT_FAMILY_LABELS) as LabelFontFamily[]).map((family) => (
<SelectItem key={family} value={family}>
{FONT_FAMILY_LABELS[family]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(fontFamily) => onChange({ fontFamily })}
labels={FONT_FAMILY_LABELS}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
@@ -249,21 +219,14 @@ function TextFields({
>
<Italic className="size-3.5" />
</Button>
<Select
<EnumSelect
id="element-text-align"
ariaLabel="Ausrichtung"
className="w-auto flex-1"
value={element.align}
onValueChange={(value) => onChange({ align: value as LabelTextAlign })}
>
<SelectTrigger className="flex-1" id="element-text-align">
<SelectValue>{ALIGN_LABELS[element.align]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(ALIGN_LABELS) as LabelTextAlign[]).map((align) => (
<SelectItem key={align} value={align}>
{ALIGN_LABELS[align]}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={(align) => onChange({ align })}
labels={ALIGN_LABELS}
/>
</div>
</>
);
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LabelDesigner } from "./label-designer";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
}));
describe("LabelDesigner", () => {
it("keeps the placeholder menu labelled as an action after inserting, never showing the raw key", async () => {
render(<LabelDesigner onSubmit={vi.fn()} submitLabel="Anlegen" />);
const insertMenu = screen.getByText("Platzhalter einfügen").closest('[role="combobox"]');
expect(insertMenu).not.toBeNull();
await userEvent.click(insertMenu as HTMLElement);
await userEvent.click(await screen.findByRole("option", { name: "Chargennummer" }));
// The placeholder element was added and is now edited in the properties panel.
expect(await screen.findByRole("combobox", { name: "Inhalt" })).toHaveTextContent(
"Chargennummer",
);
expect(insertMenu).toHaveTextContent("Platzhalter einfügen");
expect(insertMenu).not.toHaveTextContent("batchNumber");
expect(insertMenu).not.toHaveTextContent("Chargennummer");
});
});
@@ -567,9 +567,18 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
<Type className="size-4" />
Text
</Button>
<Select onValueChange={(value) => handlePlaceholderInsert(value as PlaceholderKey)}>
<SelectTrigger className="w-auto" size="sm">
<SelectValue placeholder="Platzhalter einfügen" />
{/* An action menu, not a field: it never holds a value, so the
trigger keeps saying what it does instead of showing the key
just inserted. */}
<Select
value={null}
onValueChange={(value) => {
const key = PLACEHOLDER_KEYS.find((candidate) => candidate === value);
if (key) handlePlaceholderInsert(key);
}}
>
<SelectTrigger className="w-auto" size="sm" aria-label="Platzhalter einfügen">
<SelectValue>Platzhalter einfügen</SelectValue>
</SelectTrigger>
<SelectContent>
{PLACEHOLDER_KEYS.map((key) => (
@@ -661,7 +670,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
</Button>
</div>
<Card className="sticky top-4">
<Card className="sticky top-(--header-clearance)">
<CardHeader>
<CardTitle className="text-base">Eigenschaften</CardTitle>
</CardHeader>
@@ -16,6 +16,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { PermissionMatrix } from "./permission-matrix";
import { emptyToNull } from "@/lib/forms/empty-to-null";
function normalizeKey(value: string): string {
return value
@@ -63,7 +64,7 @@ export function CreateRoleDialog() {
mutation.mutate({
key,
name,
description: description || undefined,
description: emptyToNull(description),
permissionKeys: Array.from(selected),
});
}
@@ -15,13 +15,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RecordSelect } from "@/components/form/record-select";
export function CreateUserDialog() {
const queryClient = useQueryClient();
@@ -106,23 +100,14 @@ export function CreateUserDialog() {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="new-user-role">Rolle</Label>
<Select
<RecordSelect
id="new-user-role"
value={effectiveRoleId}
onValueChange={(value) => setRoleId(value ?? effectiveRoleId)}
>
<SelectTrigger id="new-user-role" className="w-full">
<SelectValue>
{activeRoles.find((role) => role.id === effectiveRoleId)?.name ?? "Rolle wählen"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{activeRoles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setRoleId}
items={activeRoles}
getLabel={(role) => role.name}
placeholder="Rolle wählen"
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchPermissionCatalog, updateRole } from "@/lib/roles/api";
import { RoleSummary } from "@/lib/roles/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -39,7 +40,7 @@ function EditRoleForm({ role, onClose }: EditRoleFormProps) {
mutationFn: (input: {
key: string;
name: string;
description?: string;
description: string | null;
permissionKeys: string[];
}) => updateRole(role.id, input),
onSuccess: () => {
@@ -55,7 +56,7 @@ function EditRoleForm({ role, onClose }: EditRoleFormProps) {
mutation.mutate({
key,
name,
description: description || undefined,
description: emptyToNull(description),
permissionKeys: Array.from(selected),
});
}
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { EditUserDialog } from "./edit-user-dialog";
import { fetchRoles } from "@/lib/roles/api";
import type { RoleSummary } from "@/lib/roles/types";
import type { User } from "@/lib/users/types";
vi.mock("@/lib/roles/api", () => ({ fetchRoles: vi.fn() }));
vi.mock("@/lib/users/api", () => ({ updateUser: vi.fn(), resetUserPassword: vi.fn() }));
function role(id: string, name: string): RoleSummary {
return {
id,
key: name.toUpperCase(),
name,
description: null,
isSystem: false,
status: "ACTIVE",
userCount: 1,
permissionKeys: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deactivatedAt: null,
};
}
const user: User = {
id: "u-1",
email: "[email protected]",
name: "Test Imker",
roleId: "cmg3xrole2",
roleKey: "HELFER",
roleName: "Helfer",
isActive: true,
createdAt: "2026-01-01T00:00:00.000Z",
deactivatedAt: null,
mfaEnabled: false,
};
describe("EditUserDialog", () => {
it("names the user's role in the select, not the role's internal id", async () => {
vi.mocked(fetchRoles).mockResolvedValue([
role("cmg3xrole1", "Admin"),
role("cmg3xrole2", "Helfer"),
]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<EditUserDialog user={user} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
const roleSelect = await screen.findByRole("combobox", { name: "Rolle" });
expect(await screen.findByText("Helfer")).toBeInTheDocument();
expect(roleSelect).toHaveTextContent("Helfer");
expect(roleSelect).not.toHaveTextContent("cmg3xrole2");
});
});
@@ -9,13 +9,7 @@ import { User } from "@/lib/users/types";
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 { RecordSelect } from "@/components/form/record-select";
import {
Dialog,
DialogContent,
@@ -100,21 +94,14 @@ function EditUserForm({ user, onSaved }: { user: User; onSaved: () => void }) {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-user-role">Rolle</Label>
<Select
<RecordSelect
id="edit-user-role"
value={roleId}
onValueChange={(value) => setRoleId(value ?? roleId)}
>
<SelectTrigger id="edit-user-role" className="w-full">
<SelectValue placeholder="Rolle wählen" />
</SelectTrigger>
<SelectContent>
{selectableRoles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
onChange={setRoleId}
items={roles ? selectableRoles : undefined}
getLabel={(role) => role.name}
placeholder="Rolle wählen"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-user-password">Neues Passwort</Label>
@@ -22,7 +22,8 @@ function isTab(value: string | null): value is Tab {
function UsersRolesPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const tab: Tab = isTab(searchParams.get("tab")) ? searchParams.get("tab") as Tab : "users";
const requestedTab = searchParams.get("tab");
const tab: Tab = isTab(requestedTab) ? requestedTab : "users";
function setTab(next: Tab) {
router.replace(`/settings/users-roles?tab=${next}`);
+5 -1
View File
@@ -191,7 +191,11 @@ export function Sidebar({ user }: { user: AuthUser }) {
: NAV_GROUPS;
return (
<aside className="relative z-10 hidden w-64 shrink-0 flex-col gap-1 overflow-y-auto bg-sidebar px-3 py-5 shadow-[var(--shadow-soft)] sm:flex">
// The page scrolls on the window; the sidebar stays put beside it and
// scrolls on its own when the nav is taller than the screen. It is a
// sibling of the page content, so its overflow cannot capture sticky
// elements there (StickySubmit).
<aside className="sticky top-0 z-10 hidden h-dvh w-64 shrink-0 flex-col gap-1 self-start overflow-y-auto bg-sidebar px-3 py-5 shadow-[var(--shadow-soft)] sm:flex">
<Link
href="/dashboard"
className="mb-5 flex items-center gap-2.5 px-2 font-heading text-lg font-semibold"
@@ -5,6 +5,7 @@ import { BarChart3, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels";
import {
fetchApiaryStats,
fetchColonyStats,
@@ -18,35 +19,26 @@ import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
import { keysOf } from "@/lib/keys-of";
const COLONY_STATUS_LABELS: Record<string, string> = {
ACTIVE: "Aktiv",
WEAK: "Schwach",
CONSPICUOUS: "Auffällig",
QUEENLESS: "Weisellos",
DISSOLVED: "Aufgelöst",
SOLD: "Verkauft",
DECEASED: "Verstorben",
MERGED: "Vereinigt",
};
const TAB_LABELS = {
colonies: "Völker",
queens: "Königinnen",
apiaries: "Bienenstände",
varroa: "Varroa",
honey: "Honig",
feeding: "Futter",
} as const;
const TABS = [
{ value: "colonies", label: "Völker" },
{ value: "queens", label: "Königinnen" },
{ value: "apiaries", label: "Bienenstände" },
{ value: "varroa", label: "Varroa" },
{ value: "honey", label: "Honig" },
{ value: "feeding", label: "Futter" },
] as const;
type StatsTab = keyof typeof TAB_LABELS;
type StatsTab = (typeof TABS)[number]["value"];
const TABS = keysOf(TAB_LABELS);
/** The tab the tab bar reported, typed - or undefined for anything else. */
function toStatsTab(value: unknown): StatsTab | undefined {
return TABS.find((tab) => tab === value);
}
/** Feedings are stored with a free unit (mostly litres, sometimes kg) and summed as entered. */
const FEED_UNIT = "l/kg";
@@ -353,21 +345,16 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
);
}
/** On phones the tab bar does not fit - the same choice as a select. */
function TabPicker({ value, onChange }: { value: StatsTab; onChange: (value: StatsTab) => void }) {
const current = TABS.find((tab) => tab.value === value);
return (
<Select value={value} onValueChange={(next) => (next ? onChange(next as StatsTab) : undefined)}>
<SelectTrigger className="h-11 w-full sm:hidden" aria-label="Bereich wählen">
<SelectValue>{current?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{TABS.map((tab) => (
<SelectItem key={tab.value} value={tab.value} className="min-h-11">
{tab.label}
</SelectItem>
))}
</SelectContent>
</Select>
<EnumSelect
ariaLabel="Bereich wählen"
labels={TAB_LABELS}
value={value}
onChange={onChange}
className="sm:hidden"
/>
);
}
@@ -388,12 +375,12 @@ export default function StatisticsPage() {
gradient="var(--gradient-sky)"
/>
<Tabs value={tab} onValueChange={(next) => setTab(next as StatsTab)}>
<Tabs value={tab} onValueChange={(next) => setTab(toStatsTab(next) ?? tab)}>
<TabPicker value={tab} onChange={setTab} />
<TabsList className="hidden sm:inline-flex">
{TABS.map((entry) => (
<TabsTrigger key={entry.value} value={entry.value}>
{entry.label}
<TabsTrigger key={entry} value={entry}>
{TAB_LABELS[entry]}
</TabsTrigger>
))}
</TabsList>
@@ -16,13 +16,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
export function CreateStockItemDialog() {
const queryClient = useQueryClient();
@@ -55,7 +49,8 @@ export function CreateStockItemDialog() {
name,
category,
unit,
quantity: quantity ? Number(quantity) : undefined,
// Omitted, not null, when empty: the column is required with default 0.
quantity: quantity.trim() ? Number(quantity) : undefined,
});
}
@@ -78,21 +73,13 @@ export function CreateStockItemDialog() {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-category">Kategorie</Label>
<Select
<EnumSelect
id="item-category"
value={category}
onValueChange={(value) => setCategory((value ?? "CONSUMABLE") as StockCategory)}
>
<SelectTrigger id="item-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>
onChange={setCategory}
values={STOCK_CATEGORIES}
labels={STOCK_CATEGORY_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-unit">Einheit</Label>
@@ -14,6 +14,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { emptyToNull } from "@/lib/forms/empty-to-null";
export function CreateStorageContainerDialog() {
const queryClient = useQueryClient();
@@ -45,9 +46,9 @@ export function CreateStorageContainerDialog() {
setError(null);
mutation.mutate({
code,
containerType: containerType || undefined,
capacity: capacity ? Number(capacity) : undefined,
location: location || undefined,
containerType: emptyToNull(containerType),
capacity: emptyToNull(capacity, Number),
location: emptyToNull(location),
});
}
@@ -5,6 +5,7 @@ import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateStockItem } from "@/lib/storage/api";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { StockCategory, StockItem } from "@/lib/storage/types";
import { STOCK_CATEGORIES, STOCK_CATEGORY_LABELS } from "@/lib/storage/labels";
import { Button } from "@/components/ui/button";
@@ -19,13 +20,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EnumSelect } from "@/components/form/enum-select";
export function EditStockItemDialog({ item }: { item: StockItem }) {
const queryClient = useQueryClient();
@@ -44,8 +39,8 @@ export function EditStockItemDialog({ item }: { item: StockItem }) {
category,
unit,
quantity: Number(quantity),
minThreshold: minThreshold ? Number(minThreshold) : undefined,
notes: notes || undefined,
minThreshold: emptyToNull(minThreshold, Number),
notes: emptyToNull(notes),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["stock-items"] });
@@ -86,21 +81,13 @@ export function EditStockItemDialog({ item }: { item: StockItem }) {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-edit-category">Kategorie</Label>
<Select
<EnumSelect
id="item-edit-category"
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>
onChange={setCategory}
values={STOCK_CATEGORIES}
labels={STOCK_CATEGORY_LABELS}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="item-edit-unit">Einheit</Label>
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more