Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export
# Conflicts: # ARCHITECTURE.md
This commit is contained in:
commit
9c332d4716
310 files changed
+12177
-3746
No files matched your search
@@ -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({
|
||||
|
||||
+9
-22
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
-5
@@ -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 „Hergestellte Ware“.
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+16
-60
@@ -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">
|
||||
|
||||
+21
-58
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+27
@@ -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");
|
||||
});
|
||||
});
|
||||
+13
-4
@@ -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}`);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user