feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)

- lib/hive: frontend port of the hive domain (ops, validator, facts,
  layout, follow-ups, describe texts, reason texts) running the backend
  fixtures, API client
- hive card on the colony page (same height as Stammdaten): SVG view,
  accessible outline tree, frame/part sheets with optimistic save,
  conflict sheet, history with past states, init from template or
  another colony, inspection draft mode
- follow-up toasts prefilling feeding and treatment forms (treatment
  end only with an open treatment)
- settings page "Beutensysteme" (hive-systems:manage)
- NumberStepper decimal steps, reactive motion level hook, holo tokens
- spec: owner design decision 2026-10-16 (glass hologram, focus mode)

The SVG view is the fallback; the 3D scene follows in L3.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent 0091b3d28b
commit c608f5d405
87 files changed
+9493 -43

No files matched your search

@@ -24,7 +24,7 @@ export function ColonyTreatmentsCard({
});
return (
<Card>
<Card id="behandlungen">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Behandlungen</CardTitle>
<Button
@@ -17,8 +17,9 @@ vi.mock("@/lib/export/api", () => ({
exportColonyRecordCardPdf: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => null }));
vi.mock("./colony-actions", () => ({ ColonyActions: () => null }));
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => <div data-testid="edit-card" /> }));
vi.mock("./colony-actions", () => ({ ColonyActions: () => <div data-testid="actions" /> }));
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => <div data-testid="hive-card" /> }));
vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null }));
vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null }));
vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null }));
@@ -159,3 +160,51 @@ describe("ColonyDetailPage – ?editTreatment", () => {
expect(await screen.findByText("Behandlungen, Dialog für keine")).toBeInTheDocument();
});
});
describe("ColonyDetailPage – grid", () => {
beforeEach(() => {
vi.mocked(fetchColony).mockReset().mockResolvedValue({
id: "colony-1",
code: "V-01",
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
} as Colony);
});
it("puts Stammdaten and Beute in one grid row that stretches both to the same height", async () => {
renderPage();
const edit = await screen.findByTestId("edit-card");
const hive = screen.getByTestId("hive-card");
expect(edit.parentElement).toBe(hive.parentElement);
const row = edit.parentElement!;
expect(row).toHaveClass("grid", "grid-cols-1", "gap-4", "lg:grid-cols-3");
expect(row).not.toHaveClass("items-start");
});
it("keeps the order Stammdaten, Beute, Aktionen, Karten", async () => {
renderPage();
const nodes = [
await screen.findByTestId("edit-card"),
screen.getByTestId("hive-card"),
screen.getByTestId("actions"),
screen.getByText("Behandlungen, Dialog für keine"),
];
for (let i = 1; i < nodes.length; i += 1) {
expect(nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
});
it("starts the second row (Aktionen, Karten) with items-start", async () => {
renderPage();
const actions = await screen.findByTestId("actions");
const secondRow = actions.closest(".grid")!;
expect(secondRow).toHaveClass("lg:grid-cols-3", "items-start");
expect(secondRow.contains(screen.getByText("Behandlungen, Dialog für keine"))).toBe(true);
expect(secondRow.contains(screen.getByTestId("hive-card"))).toBe(false);
});
});
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
import { HiveCard } from "@/components/hive/hive-card";
import { NextStepsCard } from "@/components/autopilot/next-steps-card";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
@@ -88,9 +89,13 @@ export default function ColonyDetailPage() {
/>
{strengthText ? <p className="text-sm text-muted-foreground">{strengthText}</p> : null}
<NextStepsCard colonyId={colony.id} />
{/* Row 1 has no items-start: the grid row stretches Stammdaten and Beute to one height (spec 9.2). */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ColonyEditCard key={colony.id} colony={colony} />
<HiveCard key={`hive-${colony.id}`} colonyId={colony.id} colony={colony} />
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<ColonyEditCard key={colony.id} colony={colony} />
<ColonyActions colony={colony} />
<div>
<QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für die Beute" />
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { initialFeedingValues } from "./feeding-form-values";
const params = (query: string) => new URLSearchParams(query);
describe("initialFeedingValues prefill", () => {
it("keeps the defaults without search params", () => {
const values = initialFeedingValues("c1", params("colonyId=c1"));
expect(values).toMatchObject({ colonyId: "c1", amount: "", unit: "l", feedType: "SUGAR_WATER" });
});
it("reads date, amount, unit and feedType from the search params", () => {
const values = initialFeedingValues(
"c1",
params("colonyId=c1&date=2026-10-12&amount=3&unit=l&feedType=FEED_PASTE"),
);
expect(values).toMatchObject({
date: "2026-10-12",
amount: "3",
unit: "l",
feedType: "FEED_PASTE",
});
});
it("accepts a decimal amount", () => {
expect(initialFeedingValues("c1", params("amount=2.5&unit=kg")).amount).toBe("2.5");
});
it.each([
["date=2026-02-30", "date"],
["date=morgen", "date"],
["amount=abc", "amount"],
["amount=-1", "amount"],
["amount=0", "amount"],
["amount=Infinity", "amount"],
["unit=Eimer", "unit"],
["feedType=HONEY", "feedType"],
] as const)("ignores the invalid value in %s", (query, key) => {
const defaults = initialFeedingValues("c1");
const values = initialFeedingValues("c1", params(query));
expect(values[key]).toBe(defaults[key]);
});
});
@@ -4,6 +4,8 @@ import type { BulkCreateFeedingInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { prefillAmount, prefillDay, type ParamSource } from "@/lib/forms/prefill-params";
import { FEED_TYPES } from "@/lib/feedings/labels";
/** Common units as one tap each; the API still receives a plain string. */
export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [
@@ -28,16 +30,22 @@ export interface FeedingFormValues {
notes: string;
}
export function initialFeedingValues(colonyId: string): FeedingFormValues {
/**
* Start values. Search params `date`, `amount`, `unit`, `feedType` (the hive
* card's „Als Fütterung eintragen“ link) prefill the fields; invalid ones are ignored.
*/
export function initialFeedingValues(colonyId: string, params?: ParamSource): FeedingFormValues {
const unit = params?.get("unit") ?? "";
const feedType = params?.get("feedType") ?? "";
return {
colonyId,
target: "single",
bulkApiaryId: null,
bulkColonyIds: [],
date: todayLocalIsoDate(),
feedType: "SUGAR_WATER",
amount: "",
unit: "l",
date: (params && prefillDay(params.get("date"))) ?? todayLocalIsoDate(),
feedType: FEED_TYPES.find((candidate) => candidate === feedType) ?? "SUGAR_WATER",
amount: (params && prefillAmount(params.get("amount"))) ?? "",
unit: FEEDING_UNIT_OPTIONS.some((option) => option.value === unit) ? unit : "l",
product: "",
batchNumber: "",
notes: "",
@@ -9,11 +9,13 @@ import { createFeedingsBulk } from "@/lib/bulk/api";
import { queueBulk } from "@/lib/offline/db";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
const push = vi.fn();
const search = vi.hoisted(() => ({ query: "colonyId=colony-1" }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
useSearchParams: () => new URLSearchParams(search.query),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/feedings/api", () => ({ createFeeding: vi.fn() }));
@@ -45,6 +47,7 @@ function renderPage() {
describe("NewFeedingPage", () => {
beforeEach(() => {
window.localStorage.clear();
search.query = "colonyId=colony-1";
push.mockReset();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
@@ -57,6 +60,15 @@ describe("NewFeedingPage", () => {
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
});
it("prefills date, amount, unit and feed type from the hive card link", async () => {
search.query = "colonyId=colony-1&date=2026-10-12&amount=3&unit=l&feedType=FEED_PASTE";
renderPage();
expect(await screen.findByLabelText(/^Menge/)).toHaveValue(3);
expect(screen.getByLabelText(/^Datum/)).toHaveValue("2026-10-12");
expect(screen.getByRole("radio", { name: "l" })).toBeChecked();
expect(screen.getByRole("radio", { name: FEED_TYPE_LABELS.FEED_PASTE })).toBeChecked();
});
it("marks what is required and keeps the extra fields folded", () => {
renderPage();
// Volk, Datum, Futterart, Menge, Einheit
@@ -80,7 +80,7 @@ export default function NewFeedingPage() {
const [values, setValues] = useState<FeedingFormValues>(() =>
initialFeedingValues(searchParams.get("colonyId") ?? ""),
initialFeedingValues(searchParams.get("colonyId") ?? "", searchParams),
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
@@ -51,6 +51,12 @@ describe("nav-config", () => {
expect(hrefs(["system:read"])).toContain("/settings/system");
});
it("shows „Beutensysteme“ only with hive-systems:manage", () => {
expect(hrefs([])).not.toContain("/settings/hive-systems");
expect(hrefs(["colonies:read"])).not.toContain("/settings/hive-systems");
expect(hrefs(["hive-systems:manage"])).toContain("/settings/hive-systems");
});
it("hides items the user may not see", () => {
expect(hrefs([])).not.toContain("/winter");
expect(hrefs(["winter-checks:read"])).toContain("/winter");
@@ -1,6 +1,7 @@
import {
Activity,
BarChart3,
Box,
Boxes,
CalendarDays,
Crown,
@@ -152,6 +153,12 @@ export const ADMIN_GROUP: NavGroup = {
"api-keys:manage",
],
},
{
label: "Beutensysteme",
href: "/settings/hive-systems",
icon: Box,
permission: "hive-systems:manage",
},
{
label: "Backup",
href: "/settings/backup",
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import HiveSystemsPage from "./page";
import { fetchHiveSystems } from "@/lib/hive/api";
import { updateHivePartType } from "@/lib/hive/catalog-api";
import type { HivePartTypeDto, HiveSystemDto } from "@/lib/hive/types";
vi.mock("@/lib/hive/api", async (importOriginal) => {
const original = await importOriginal<typeof import("@/lib/hive/api")>();
return { ...original, fetchHiveSystems: vi.fn() };
});
vi.mock("@/lib/hive/catalog-api", () => ({
createHiveSystem: vi.fn(),
updateHiveSystem: vi.fn(),
createHivePartType: vi.fn(),
updateHivePartType: vi.fn(),
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { permissions } }),
}));
const stamp = { createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" };
const feeder: HivePartTypeDto = {
id: "pt1",
systemId: "s1",
kind: "FEEDER",
name: "Futtertrog XL",
articleRef: null,
lengthMm: 500,
widthMm: 480,
heightMm: 200,
frameSlots: null,
boxDepth: null,
stateSchema: [
{ key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
{ key: "paste", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5 },
],
active: true,
...stamp,
};
const box: HivePartTypeDto = {
...feeder,
id: "pt2",
kind: "BOX",
name: "Zarge DNM",
heightMm: 240,
frameSlots: 10,
boxDepth: "FULL",
stateSchema: [],
active: false,
};
const dadant: HiveSystemDto = {
id: "s1",
name: "Dadant",
frameWidthMm: 435,
frameHeightMm: 300,
footprintLMm: 500,
footprintWMm: 480,
template: { schemaVersion: 1, stack: [{}, {}, {}, {}, {}, {}] },
active: true,
partTypes: [feeder, box],
...stamp,
};
const zander: HiveSystemDto = {
...dadant,
id: "s2",
name: "Zander",
template: null,
active: false,
partTypes: [],
};
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<HiveSystemsPage />
</QueryClientProvider>,
);
}
describe("HiveSystemsPage", () => {
beforeEach(() => {
permissions = ["hive-systems:manage"];
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([dadant, zander]);
vi.mocked(updateHivePartType).mockReset().mockResolvedValue({} as never);
});
it("shows the no-access state and loads nothing without the permission", () => {
permissions = ["colonies:read"];
renderPage();
expect(screen.getByText(/keine Berechtigung/i)).toBeInTheDocument();
expect(fetchHiveSystems).not.toHaveBeenCalled();
});
it("lists one card per system with chips, dimensions and template info", async () => {
renderPage();
const dadantCard = await screen.findByRole("region", { name: "Dadant" });
expect(within(dadantCard).getByText("aktiv", { selector: "[data-system-chip]" })).toBeInTheDocument();
expect(within(dadantCard).getByText(/435 × 300 mm/)).toBeInTheDocument();
expect(within(dadantCard).getByText(/500 × 480 mm/)).toBeInTheDocument();
expect(within(dadantCard).getByText("Vorlage: 6 Teile")).toBeInTheDocument();
const zanderCard = screen.getByRole("region", { name: "Zander" });
expect(within(zanderCard).getByText("ausgeblendet", { selector: "[data-system-chip]" })).toBeInTheDocument();
expect(within(zanderCard).getByText("Keine Vorlage")).toBeInTheDocument();
});
it("describes each part type: kind, size, slots and state fields", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
const feederRow = within(card).getByRole("row", { name: /Futtertrog XL/ });
expect(feederRow).toHaveTextContent("Futterzarge");
expect(feederRow).toHaveTextContent("500 × 480 × 200 mm");
expect(feederRow).toHaveTextContent("Flüssigfutter 0–3 l, Futterteig 0–2,5 kg");
expect(feederRow).toHaveTextContent("aktiv");
const boxRow = within(card).getByRole("row", { name: /Zarge DNM/ });
expect(boxRow).toHaveTextContent("10");
expect(boxRow).toHaveTextContent("ausgeblendet");
});
it("„Ausblenden“ sends active:false, „Einblenden“ active:true", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
await userEvent.click(
within(within(card).getByRole("row", { name: /Futtertrog XL/ })).getByRole("button", {
name: /Ausblenden/,
}),
);
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt1", { active: false }));
await userEvent.click(
within(within(card).getByRole("row", { name: /Zarge DNM/ })).getByRole("button", {
name: /Einblenden/,
}),
);
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { active: true }));
});
it("has no delete action anywhere", async () => {
renderPage();
await screen.findByRole("region", { name: "Dadant" });
expect(screen.queryByRole("button", { name: /löschen|entfernen/i })).not.toBeInTheDocument();
});
it("opens the dialog to add a part and to edit one", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
await userEvent.click(within(card).getByRole("button", { name: "Teil hinzufügen" }));
expect(await screen.findByRole("heading", { name: "Teil hinzufügen" })).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "Teil hinzufügen" })).not.toBeInTheDocument(),
);
await userEvent.click(
within(within(card).getByRole("row", { name: /Futtertrog XL/ })).getByRole("button", {
name: /Bearbeiten/,
}),
);
expect(await screen.findByRole("heading", { name: "Teil bearbeiten" })).toBeInTheDocument();
});
it("opens the system dialog from the card", async () => {
renderPage();
const card = await screen.findByRole("region", { name: "Dadant" });
await userEvent.click(within(card).getByRole("button", { name: "System bearbeiten" }));
expect(await screen.findByRole("heading", { name: "System bearbeiten" })).toBeInTheDocument();
});
it("says so when there is no system yet", async () => {
vi.mocked(fetchHiveSystems).mockResolvedValue([]);
renderPage();
expect(await screen.findByText(/Noch kein Beutensystem/)).toBeInTheDocument();
});
});
@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { Boxes, Pencil, Plus, ShieldOff } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
import { updateHivePartType } from "@/lib/hive/catalog-api";
import { serverMessage, templatePartCount } from "@/lib/hive/catalog-form";
import type { HivePartTypeDto, HiveSystemDto } from "@/lib/hive/types";
import { PartTypeDialog } from "./part-type-dialog";
import { ActiveChip, PartTypeTable } from "./part-type-table";
import { SystemDialog } from "./system-dialog";
const MANAGE_PERMISSION = "hive-systems:manage";
interface SystemCardProps {
system: HiveSystemDto;
pendingId: string | null;
onEditSystem: () => void;
onAddPart: () => void;
onEditPart: (partType: HivePartTypeDto) => void;
onToggleActive: (partType: HivePartTypeDto) => void;
}
function SystemCard({
system,
pendingId,
onEditSystem,
onAddPart,
onEditPart,
onToggleActive,
}: SystemCardProps) {
const headingId = `hive-system-${system.id}`;
const templateParts = templatePartCount(system.template);
return (
<section aria-labelledby={headingId}>
<Card>
<CardHeader className="gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle>
<h2 id={headingId} className="font-heading text-lg font-semibold">
{system.name}
</h2>
</CardTitle>
<ActiveChip active={system.active} data-system-chip="" />
</div>
<Button type="button" variant="outline" size="touch" onClick={onEditSystem}>
<Pencil aria-hidden />
System bearbeiten
</Button>
</div>
<p className="text-sm text-muted-foreground">
Rähmchen {system.frameWidthMm} × {system.frameHeightMm} mm · Grundfläche{" "}
{system.footprintLMm} × {system.footprintWMm} mm
</p>
<p className="text-sm text-muted-foreground">
{templateParts === null ? "Keine Vorlage" : `Vorlage: ${templateParts} Teile`}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<PartTypeTable
partTypes={system.partTypes}
pendingId={pendingId}
onEdit={onEditPart}
onToggleActive={onToggleActive}
/>
<Button type="button" size="touch" className="self-start" onClick={onAddPart}>
<Plus aria-hidden />
Teil hinzufügen
</Button>
</CardContent>
</Card>
</section>
);
}
/** The part dialog is open for a new part (no `partType`) or for an existing one. */
interface PartDialogTarget {
systemId: string;
partType?: HivePartTypeDto;
}
function HiveSystemsContent() {
const queryClient = useQueryClient();
const systemsQuery = useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems });
const [partTarget, setPartTarget] = useState<PartDialogTarget | null>(null);
const [systemTarget, setSystemTarget] = useState<{ system?: HiveSystemDto } | null>(null);
const [toggleError, setToggleError] = useState<string | null>(null);
const toggle = useMutation({
mutationFn: (partType: HivePartTypeDto) =>
updateHivePartType(partType.id, { active: !partType.active }),
onMutate: () => setToggleError(null),
onSuccess: () => queryClient.invalidateQueries({ queryKey: hiveKeys.systems }),
onError: (error: unknown) =>
setToggleError(serverMessage(error, "Die Sichtbarkeit konnte nicht geändert werden.")),
});
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Boxes}
title="Beutensysteme"
description="Welche Teile es pro System gibt, wie groß sie sind und was ihr Zustand zeigt."
actions={
<Button type="button" size="touch" onClick={() => setSystemTarget({})}>
<Plus aria-hidden />
System hinzufügen
</Button>
}
/>
{toggleError ? (
<p role="alert" className="text-sm text-destructive">
{toggleError}
</p>
) : null}
<QueryState
query={systemsQuery}
what="Beutensysteme"
empty={
<EmptyState
icon={Boxes}
title="Noch kein Beutensystem"
description="Lege ein System an, damit Völker einen Beutenaufbau bekommen können."
/>
}
>
{(systems) =>
systems.map((system) => (
<SystemCard
key={system.id}
system={system}
pendingId={toggle.isPending ? toggle.variables.id : null}
onEditSystem={() => setSystemTarget({ system })}
onAddPart={() => setPartTarget({ systemId: system.id })}
onEditPart={(partType) => setPartTarget({ systemId: system.id, partType })}
onToggleActive={(partType) => toggle.mutate(partType)}
/>
))
}
</QueryState>
<PartTypeDialog
systemId={partTarget?.systemId ?? ""}
partType={partTarget?.partType}
open={partTarget !== null}
onOpenChange={(open) => (open ? undefined : setPartTarget(null))}
/>
<SystemDialog
system={systemTarget?.system}
open={systemTarget !== null}
onOpenChange={(open) => (open ? undefined : setSystemTarget(null))}
/>
</div>
);
}
export default function HiveSystemsPage() {
const { user } = useAuth();
if (!user?.permissions.includes(MANAGE_PERMISSION)) {
return (
<EmptyState
icon={ShieldOff}
title="Du hast keine Berechtigung für die Beutensysteme."
description="Frag eine Administratorin oder einen Administrator."
/>
);
}
return <HiveSystemsContent />;
}
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PartTypeDialog } from "./part-type-dialog";
import { ApiError } from "@/lib/auth/api-client";
import { createHivePartType, updateHivePartType } from "@/lib/hive/catalog-api";
import type { HivePartTypeDto } from "@/lib/hive/types";
vi.mock("@/lib/hive/catalog-api", () => ({
createHivePartType: vi.fn(),
updateHivePartType: vi.fn(),
}));
const feeder: HivePartTypeDto = {
id: "pt1",
systemId: "s1",
kind: "FEEDER",
name: "Futterzarge",
articleRef: "FZ-1",
lengthMm: 500,
widthMm: 480,
heightMm: 200,
frameSlots: null,
boxDepth: null,
stateSchema: [
{
key: "syrup",
label: "Flüssigfutter",
kind: "level",
unit: "l",
max: 3,
step: 0.5,
feedType: "FEED_SYRUP",
},
],
active: true,
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
};
function renderDialog(partType?: HivePartTypeDto) {
const onOpenChange = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidate = vi.spyOn(client, "invalidateQueries");
render(
<QueryClientProvider client={client}>
<PartTypeDialog systemId="s1" partType={partType} open onOpenChange={onOpenChange} />
</QueryClientProvider>,
);
return { onOpenChange, invalidate };
}
const typeInto = async (label: RegExp | string, text: string) => {
const input = screen.getByLabelText(label);
await userEvent.clear(input);
await userEvent.type(input, text);
};
async function pickBox() {
await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ }));
await userEvent.click(await screen.findByRole("option", { name: "Zarge" }));
}
describe("PartTypeDialog", () => {
beforeEach(() => {
vi.mocked(createHivePartType).mockReset();
vi.mocked(updateHivePartType).mockReset();
vi.mocked(createHivePartType).mockResolvedValue({} as never);
vi.mocked(updateHivePartType).mockResolvedValue({} as never);
});
it("creates a box with frame slots and refreshes the catalog", async () => {
const { invalidate, onOpenChange } = renderDialog();
expect(screen.getByRole("heading", { name: "Teil hinzufügen" })).toBeInTheDocument();
await typeInto(/^Name/, "Zarge DNM");
await pickBox();
await typeInto(/^Länge/, "500");
await typeInto(/^Breite/, "480");
await typeInto(/^Höhe/, "240");
await typeInto(/^Rähmchenplätze/, "10");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", {
kind: "BOX",
name: "Zarge DNM",
lengthMm: 500,
widthMm: 480,
heightMm: 240,
boxDepth: "FULL",
frameSlots: 10,
}),
);
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["hive-systems"] });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("blocks saving without a name and sends nothing", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Name/);
expect(createHivePartType).not.toHaveBeenCalled();
});
it("caps frame slots at the server limit", async () => {
renderDialog();
await typeInto(/^Name/, "Zarge");
await pickBox();
await typeInto(/^Länge/, "500");
await typeInto(/^Breite/, "480");
await typeInto(/^Höhe/, "240");
await typeInto(/^Rähmchenplätze/, "25");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ frameSlots: 20 })),
);
});
it("requires the frame slots of a box", async () => {
renderDialog();
await typeInto(/^Name/, "Zarge");
await pickBox();
await typeInto(/^Länge/, "500");
await typeInto(/^Breite/, "480");
await typeInto(/^Höhe/, "240");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Rähmchenplätze/);
expect(createHivePartType).not.toHaveBeenCalled();
});
it("edit: sends only the changed dimension as PATCH", async () => {
const { onOpenChange } = renderDialog(feeder);
expect(screen.getByRole("heading", { name: "Teil bearbeiten" })).toBeInTheDocument();
await typeInto(/^Höhe/, "250");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt1", { heightMm: 250 }));
expect(createHivePartType).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("edit: the kind is shown but cannot be changed", () => {
renderDialog(feeder);
expect(screen.queryByRole("combobox", { name: /Art des Teils/ })).not.toBeInTheDocument();
expect(screen.getByText("Futterzarge", { selector: "dd" })).toBeInTheDocument();
});
it("existing state fields are read-only and cannot be removed", () => {
renderDialog(feeder);
const list = screen.getByRole("list", { name: "Bestehende Zustandsfelder" });
expect(within(list).getByText(/Flüssigfutter/)).toBeInTheDocument();
expect(within(list).queryByRole("button")).not.toBeInTheDocument();
expect(within(list).queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /entfernen|löschen/i })).not.toBeInTheDocument();
});
it("appends a level field after the existing ones", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await typeInto(/^Schlüssel/, "paste");
await typeInto(/^Feldname/, "Futterteig");
await typeInto(/^Einheit/, "kg");
await typeInto(/^Höchstwert/, "2,5");
await typeInto(/^Schrittweite/, "0,5");
await userEvent.click(screen.getByRole("combobox", { name: /Futtertyp/ }));
await userEvent.click(await screen.findByRole("option", { name: "Futterteig" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(updateHivePartType).toHaveBeenCalledWith("pt1", {
stateSchema: [
feeder.stateSchema[0],
{
key: "paste",
label: "Futterteig",
kind: "level",
unit: "kg",
max: 2.5,
step: 0.5,
feedType: "FEED_PASTE",
},
],
}),
);
});
it("appends a choice field with option labels", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await userEvent.click(screen.getByRole("combobox", { name: /Art des Feldes/ }));
await userEvent.click(await screen.findByRole("option", { name: "Auswahl" }));
await typeInto(/^Schlüssel/, "lid");
await typeInto(/^Feldname/, "Deckel");
await typeInto("Option 1 Wert", "open");
await typeInto("Option 1 Bezeichnung", "offen");
await userEvent.click(screen.getByRole("button", { name: "Option hinzufügen" }));
await typeInto("Option 2 Wert", "closed");
await typeInto("Option 2 Bezeichnung", "zu");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(updateHivePartType).toHaveBeenCalledWith("pt1", {
stateSchema: [
feeder.stateSchema[0],
{
key: "lid",
label: "Deckel",
kind: "choice",
options: ["open", "closed"],
optionLabels: ["offen", "zu"],
},
],
}),
);
});
it("refuses a new field whose key already exists", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await typeInto(/^Schlüssel/, "syrup");
await typeInto(/^Feldname/, "Nochmal");
await typeInto(/^Höchstwert/, "1");
await typeInto(/^Schrittweite/, "1");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Schlüssel/);
expect(updateHivePartType).not.toHaveBeenCalled();
});
it("a new field can be dropped again before saving", async () => {
renderDialog(feeder);
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
await userEvent.click(screen.getByRole("button", { name: "Neues Feld verwerfen" }));
expect(screen.queryByLabelText(/^Schlüssel/)).not.toBeInTheDocument();
});
it("shows the server 400 message inline", async () => {
vi.mocked(updateHivePartType).mockRejectedValue(
new ApiError(400, "x", {
code: "SCHEMA_INVALID",
messages: ["stateSchema[1].max must be positive"],
}),
);
const { onOpenChange } = renderDialog(feeder);
await typeInto(/^Höhe/, "250");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive");
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,340 @@
"use client";
import { FormEvent, useState } from "react";
import { Plus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { hiveKeys } from "@/lib/hive/api";
import {
createHivePartType,
updateHivePartType,
type HivePartTypeInput,
type HivePartTypePatch,
} from "@/lib/hive/catalog-api";
import {
MAX_DIMENSION_MM,
MAX_FRAME_SLOTS,
draftProblem,
emptyDraft,
fieldFromDraft,
serverMessage,
type FieldDraft,
} from "@/lib/hive/catalog-form";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto } from "@/lib/hive/types";
import { StateFieldEditor } from "./state-field-editor";
import { StateFieldList } from "./state-field-list";
const BOX_DEPTH_LABELS: Record<HiveBoxDepth, string> = {
FULL: "Volle Zarge",
SHALLOW: "Flachzarge",
};
const MAX_NAME_LENGTH = 120;
const MAX_ARTICLE_REF_LENGTH = 80;
const DIMENSION_STEP_MM = 10;
interface PartTypeFormProps {
systemId: string;
partType: HivePartTypeDto | undefined;
onSaved: () => void;
}
interface FormValues {
name: string;
articleRef: string;
kind: HivePartKind | "";
lengthMm: string;
widthMm: string;
heightMm: string;
boxDepth: HiveBoxDepth;
frameSlots: string;
}
function initialValues(partType: HivePartTypeDto | undefined): FormValues {
return {
name: partType?.name ?? "",
articleRef: partType?.articleRef ?? "",
kind: partType?.kind ?? "",
lengthMm: partType ? String(partType.lengthMm) : "",
widthMm: partType ? String(partType.widthMm) : "",
heightMm: partType ? String(partType.heightMm) : "",
boxDepth: partType?.boxDepth ?? "FULL",
frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots),
};
}
function isDimension(text: string): boolean {
const value = Number(text);
return text !== "" && Number.isInteger(value) && value >= 1 && value <= MAX_DIMENSION_MM;
}
function valuesProblem(values: FormValues): string | null {
if (values.name.trim() === "") return "Bitte einen Namen eingeben.";
if (values.kind === "") return "Bitte die Art des Teils wählen.";
if (![values.lengthMm, values.widthMm, values.heightMm].every(isDimension)) {
return `Länge, Breite und Höhe müssen ganze Millimeter zwischen 1 und ${MAX_DIMENSION_MM} sein.`;
}
if (values.kind === "BOX" && values.frameSlots === "") {
return "Eine Zarge braucht die Zahl der Rähmchenplätze.";
}
return null;
}
/** Only what differs from the stored part type, so a PATCH never touches the rest. */
function patchFrom(
values: FormValues,
partType: HivePartTypeDto,
newField: FieldDraft | null,
): HivePartTypePatch {
const patch: HivePartTypePatch = {};
const name = values.name.trim();
if (name !== partType.name) patch.name = name;
const articleRef = values.articleRef.trim();
if (articleRef !== (partType.articleRef ?? "")) patch.articleRef = articleRef === "" ? null : articleRef;
if (Number(values.lengthMm) !== partType.lengthMm) patch.lengthMm = Number(values.lengthMm);
if (Number(values.widthMm) !== partType.widthMm) patch.widthMm = Number(values.widthMm);
if (Number(values.heightMm) !== partType.heightMm) patch.heightMm = Number(values.heightMm);
if (partType.kind === "BOX") {
if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth;
if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots);
}
if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)];
return patch;
}
function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTypeInput {
const kind = values.kind as HivePartKind;
const articleRef = values.articleRef.trim();
return {
kind,
name: values.name.trim(),
...(articleRef === "" ? {} : { articleRef }),
lengthMm: Number(values.lengthMm),
widthMm: Number(values.widthMm),
heightMm: Number(values.heightMm),
...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}),
...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}),
};
}
function DimensionField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Field label={label} required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={value}
onChange={onChange}
min={1}
max={MAX_DIMENSION_MM}
step={DIMENSION_STEP_MM}
unitLabel="mm"
decrementLabel={`${label} verringern`}
incrementLabel={`${label} erhöhen`}
/>
)}
</Field>
);
}
function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
const queryClient = useQueryClient();
const [values, setValues] = useState(() => initialValues(partType));
const [newField, setNewField] = useState<FieldDraft | null>(null);
const [error, setError] = useState<string | null>(null);
const existingSchema = partType?.stateSchema ?? [];
const setValue = <K extends keyof FormValues>(key: K, value: FormValues[K]) =>
setValues((current) => ({ ...current, [key]: value }));
const mutation = useMutation({
mutationFn: async () => {
if (!partType) return createHivePartType(systemId, inputFrom(values, newField));
const patch = patchFrom(values, partType, newField);
// Nothing changed: no request needed.
if (Object.keys(patch).length > 0) await updateHivePartType(partType.id, patch);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
onSaved();
},
onError: (mutationError: unknown) =>
setError(serverMessage(mutationError, "Das Teil konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const problem =
valuesProblem(values) ??
(newField
? draftProblem(newField, existingSchema.map((field) => field.key))
: null);
setError(problem);
if (problem === null) mutation.mutate();
}
const isBox = values.kind === "BOX";
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field label="Name" required>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_NAME_LENGTH}
value={values.name}
onChange={(event) => setValue("name", event.target.value)}
/>
)}
</Field>
<Field label="Artikelnummer">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_ARTICLE_REF_LENGTH}
value={values.articleRef}
onChange={(event) => setValue("articleRef", event.target.value)}
/>
)}
</Field>
{partType ? (
<dl className="flex flex-col gap-1 text-sm">
<dt className="font-medium">Art des Teils</dt>
<dd>{PART_KIND_LABELS[partType.kind]}</dd>
</dl>
) : (
<Field label="Art des Teils" required hint="Lässt sich später nicht mehr ändern.">
{({ id, describedBy }) => (
<EnumSelect
id={id}
describedBy={describedBy}
labels={PART_KIND_LABELS}
noneLabel="Bitte wählen"
noneIsPlaceholder
value={values.kind}
onChange={(kind) => setValue("kind", kind)}
/>
)}
</Field>
)}
<DimensionField label="Länge" value={values.lengthMm} onChange={(v) => setValue("lengthMm", v)} />
<DimensionField label="Breite" value={values.widthMm} onChange={(v) => setValue("widthMm", v)} />
<DimensionField label="Höhe" value={values.heightMm} onChange={(v) => setValue("heightMm", v)} />
{isBox ? (
<>
<Field label="Bauart der Zarge" required>
{({ id, describedBy }) => (
<EnumSelect
id={id}
describedBy={describedBy}
labels={BOX_DEPTH_LABELS}
value={values.boxDepth}
onChange={(boxDepth) => setValue("boxDepth", boxDepth)}
/>
)}
</Field>
<Field label="Rähmchenplätze" required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.frameSlots}
onChange={(frameSlots) => setValue("frameSlots", frameSlots)}
max={MAX_FRAME_SLOTS}
unitLabel="Plätze"
decrementLabel="Rähmchenplätze verringern"
incrementLabel="Rähmchenplätze erhöhen"
/>
)}
</Field>
</>
) : null}
<section className="flex flex-col gap-3" aria-labelledby="state-fields-heading">
<h3 id="state-fields-heading" className="text-sm font-medium">
Zustandsfelder
</h3>
<StateFieldList fields={existingSchema} />
{newField ? (
<StateFieldEditor
draft={newField}
onChange={setNewField}
onDiscard={() => setNewField(null)}
allowFeedType={values.kind === "FEEDER"}
/>
) : (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
onClick={() => setNewField(emptyDraft())}
>
<Plus aria-hidden />
Feld hinzufügen
</Button>
)}
</section>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
);
}
interface PartTypeDialogProps {
systemId: string;
/** Without a part type the dialog adds a new one. */
partType?: HivePartTypeDto;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function PartTypeDialog({ systemId, partType, open, onOpenChange }: PartTypeDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{partType ? "Teil bearbeiten" : "Teil hinzufügen"}</DialogTitle>
</DialogHeader>
{/* Mounted only while open, so every opening starts from the stored values. */}
{open ? (
<PartTypeForm
key={partType?.id ?? "new"}
systemId={systemId}
partType={partType}
onSaved={() => onOpenChange(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
"use client";
import { Eye, EyeOff, Pencil } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { describeStateFields } from "@/lib/hive/catalog-form";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import type { HivePartTypeDto } from "@/lib/hive/types";
/** Active or hidden in words plus an icon - never colour alone. */
interface ActiveChipProps {
active: boolean;
/** Marker for the chip of a whole system (tests tell it from the part type chips). */
"data-system-chip"?: string;
}
export function ActiveChip({ active, ...rest }: ActiveChipProps) {
const Icon = active ? Eye : EyeOff;
return (
<Badge variant={active ? "secondary" : "outline"} {...rest}>
<Icon aria-hidden />
{active ? "aktiv" : "ausgeblendet"}
</Badge>
);
}
interface PartTypeTableProps {
partTypes: readonly HivePartTypeDto[];
/** The part type whose visibility is being changed right now. */
pendingId: string | null;
onEdit: (partType: HivePartTypeDto) => void;
onToggleActive: (partType: HivePartTypeDto) => void;
}
export function PartTypeTable({ partTypes, pendingId, onEdit, onToggleActive }: PartTypeTableProps) {
if (partTypes.length === 0) {
return <p className="text-sm text-muted-foreground">Noch keine Teile in diesem System.</p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Art</TableHead>
<TableHead>Maße</TableHead>
<TableHead>Plätze</TableHead>
<TableHead>Zustandsfelder</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{partTypes.map((partType) => (
<TableRow key={partType.id} className={partType.active ? undefined : "text-muted-foreground"}>
<TableCell className="font-medium">{partType.name}</TableCell>
<TableCell>{PART_KIND_LABELS[partType.kind]}</TableCell>
<TableCell className="whitespace-nowrap">
{partType.lengthMm} × {partType.widthMm} × {partType.heightMm} mm
</TableCell>
<TableCell>{partType.frameSlots ?? "–"}</TableCell>
<TableCell className="min-w-48 whitespace-normal">
{describeStateFields(partType.stateSchema)}
</TableCell>
<TableCell>
<ActiveChip active={partType.active} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="touch" onClick={() => onEdit(partType)}>
<Pencil aria-hidden />
Bearbeiten<span className="sr-only"> {partType.name}</span>
</Button>
<Button
type="button"
variant="outline"
size="touch"
disabled={pendingId === partType.id}
onClick={() => onToggleActive(partType)}
>
{partType.active ? <EyeOff aria-hidden /> : <Eye aria-hidden />}
{partType.active ? "Ausblenden" : "Einblenden"}
<span className="sr-only"> {partType.name}</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,193 @@
"use client";
import { Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import {
MAX_FIELD_LABEL_LENGTH,
MAX_KEY_LENGTH,
MAX_OPTION_LENGTH,
MAX_UNIT_LENGTH,
type FieldDraft,
} from "@/lib/hive/catalog-form";
const FIELD_KIND_LABELS = { level: "Füllstand (Zahl)", choice: "Auswahl" } as const;
interface StateFieldEditorProps {
draft: FieldDraft;
onChange: (draft: FieldDraft) => void;
onDiscard: () => void;
/** Only feeders may tie a level to a feed type. */
allowFeedType: boolean;
}
function LevelInputs({ draft, onChange, allowFeedType }: Omit<StateFieldEditorProps, "onDiscard">) {
return (
<>
<Field label="Einheit" hint="Zum Beispiel l oder kg.">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_UNIT_LENGTH}
value={draft.unit}
onChange={(event) => onChange({ ...draft, unit: event.target.value })}
/>
)}
</Field>
<Field label="Höchstwert" required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={draft.max}
onChange={(max) => onChange({ ...draft, max })}
decimals={2}
step={0.5}
unitLabel={draft.unit || "Einheiten"}
decrementLabel="Höchstwert verringern"
incrementLabel="Höchstwert erhöhen"
/>
)}
</Field>
<Field label="Schrittweite" required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={draft.step}
onChange={(step) => onChange({ ...draft, step })}
decimals={2}
step={0.25}
unitLabel={draft.unit || "Einheiten"}
decrementLabel="Schrittweite verringern"
incrementLabel="Schrittweite erhöhen"
/>
)}
</Field>
{allowFeedType ? (
<Field label="Futtertyp" hint="Damit schlägt die App eine Fütterung vor.">
{({ id, describedBy }) => (
<EnumSelect
id={id}
describedBy={describedBy}
labels={FEED_TYPE_LABELS}
values={FEED_TYPES}
noneLabel="Kein Futtertyp"
value={draft.feedType}
onChange={(feedType) => onChange({ ...draft, feedType })}
/>
)}
</Field>
) : null}
</>
);
}
function OptionRows({ draft, onChange }: Pick<StateFieldEditorProps, "draft" | "onChange">) {
const setOption = (index: number, patch: Partial<FieldDraft["options"][number]>) =>
onChange({
...draft,
options: draft.options.map((option, i) => (i === index ? { ...option, ...patch } : option)),
});
return (
<div className="flex flex-col gap-2">
{draft.options.map((option, index) => (
<div key={index} className="flex items-end gap-2">
<Input
aria-label={`Option ${index + 1} Wert`}
placeholder="Wert"
maxLength={MAX_OPTION_LENGTH}
value={option.value}
onChange={(event) => setOption(index, { value: event.target.value })}
/>
<Input
aria-label={`Option ${index + 1} Bezeichnung`}
placeholder="Bezeichnung"
maxLength={MAX_OPTION_LENGTH}
value={option.label}
onChange={(event) => setOption(index, { label: event.target.value })}
/>
{draft.options.length > 1 ? (
<Button
type="button"
variant="ghost"
size="touch"
aria-label={`Option ${index + 1} entfernen`}
onClick={() =>
onChange({ ...draft, options: draft.options.filter((_, i) => i !== index) })
}
>
<X aria-hidden />
</Button>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
onClick={() => onChange({ ...draft, options: [...draft.options, { value: "", label: "" }] })}
>
<Plus aria-hidden />
Option hinzufügen
</Button>
</div>
);
}
/** Inputs of the one new state field; it is appended to the schema on save. */
export function StateFieldEditor({ draft, onChange, onDiscard, allowFeedType }: StateFieldEditorProps) {
return (
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Neues Zustandsfeld</legend>
<Field label="Art des Feldes" required>
{({ id }) => (
<EnumSelect
id={id}
labels={FIELD_KIND_LABELS}
value={draft.kind}
onChange={(kind) => onChange({ ...draft, kind })}
/>
)}
</Field>
<Field label="Schlüssel" required hint="Technischer Name, später nicht mehr änderbar.">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_KEY_LENGTH}
value={draft.key}
onChange={(event) => onChange({ ...draft, key: event.target.value })}
/>
)}
</Field>
<Field label="Feldname" required>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_FIELD_LABEL_LENGTH}
value={draft.label}
onChange={(event) => onChange({ ...draft, label: event.target.value })}
/>
)}
</Field>
{draft.kind === "level" ? (
<LevelInputs draft={draft} onChange={onChange} allowFeedType={allowFeedType} />
) : (
<OptionRows draft={draft} onChange={onChange} />
)}
<Button type="button" variant="ghost" size="touch" className="self-start" onClick={onDiscard}>
<X aria-hidden />
Neues Feld verwerfen
</Button>
</fieldset>
);
}
@@ -0,0 +1,29 @@
import type { StateField } from "@/lib/hive/types";
function summary(field: StateField): string {
if (field.kind === "choice") return `Auswahl: ${field.optionLabels.join(" / ")}`;
const unit = field.unit === "" ? "" : ` ${field.unit}`;
return `Füllstand 0–${String(field.max).replace(".", ",")}${unit}`;
}
/**
* Existing state fields, read only: the schema may only grow (the server
* answers SCHEMA_NOT_ADDITIVE otherwise), so there is nothing to edit or remove.
*/
export function StateFieldList({ fields }: { fields: readonly StateField[] }) {
if (fields.length === 0) {
return <p className="text-sm text-muted-foreground">Noch keine Zustandsfelder.</p>;
}
return (
<ul aria-label="Bestehende Zustandsfelder" className="flex flex-col gap-1 text-sm">
{fields.map((field) => (
<li key={field.key} className="rounded-lg bg-muted/50 px-3 py-2">
<span className="font-medium">{field.label}</span>{" "}
<span className="text-muted-foreground">
({field.key}) · {summary(field)}
</span>
</li>
))}
</ul>
);
}
@@ -0,0 +1,82 @@
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 { SystemDialog } from "./system-dialog";
import { createHiveSystem, updateHiveSystem } from "@/lib/hive/catalog-api";
import type { HiveSystemDto } from "@/lib/hive/types";
vi.mock("@/lib/hive/catalog-api", () => ({
createHiveSystem: vi.fn(),
updateHiveSystem: vi.fn(),
}));
const system: HiveSystemDto = {
id: "s1",
name: "Dadant",
frameWidthMm: 435,
frameHeightMm: 300,
footprintLMm: 500,
footprintWMm: 480,
template: null,
active: true,
partTypes: [],
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
};
function renderDialog(value?: HiveSystemDto) {
const onOpenChange = vi.fn();
const client = new QueryClient();
const invalidate = vi.spyOn(client, "invalidateQueries");
render(
<QueryClientProvider client={client}>
<SystemDialog system={value} open onOpenChange={onOpenChange} />
</QueryClientProvider>,
);
return { onOpenChange, invalidate };
}
const typeInto = async (label: RegExp, text: string) => {
const input = screen.getByLabelText(label);
await userEvent.clear(input);
await userEvent.type(input, text);
};
describe("SystemDialog", () => {
beforeEach(() => {
vi.mocked(createHiveSystem).mockReset().mockResolvedValue({} as never);
vi.mocked(updateHiveSystem).mockReset().mockResolvedValue({} as never);
});
it("edit: sends the changed footprint as PATCH and refreshes the catalog", async () => {
const { invalidate, onOpenChange } = renderDialog(system);
await typeInto(/^Grundfläche Länge/, "520");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateHiveSystem).toHaveBeenCalledWith("s1", { footprintLMm: 520 }));
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["hive-systems"] });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("create: needs a name and sends all four dimensions", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Name/);
await typeInto(/^Name/, "Zander");
await typeInto(/^Rähmchen Breite/, "420");
await typeInto(/^Rähmchen Höhe/, "220");
await typeInto(/^Grundfläche Länge/, "450");
await typeInto(/^Grundfläche Breite/, "430");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHiveSystem).toHaveBeenCalledWith({
name: "Zander",
frameWidthMm: 420,
frameHeightMm: 220,
footprintLMm: 450,
footprintWMm: 430,
}),
);
});
});
@@ -0,0 +1,193 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import { hiveKeys } from "@/lib/hive/api";
import {
createHiveSystem,
updateHiveSystem,
type HiveSystemInput,
type HiveSystemPatch,
} from "@/lib/hive/catalog-api";
import { MAX_DIMENSION_MM, serverMessage } from "@/lib/hive/catalog-form";
import type { HiveSystemDto } from "@/lib/hive/types";
const MAX_NAME_LENGTH = 120;
const DIMENSION_STEP_MM = 5;
type DimensionKey = "frameWidthMm" | "frameHeightMm" | "footprintLMm" | "footprintWMm";
const DIMENSIONS: { key: DimensionKey; label: string }[] = [
{ key: "frameWidthMm", label: "Rähmchen Breite" },
{ key: "frameHeightMm", label: "Rähmchen Höhe" },
{ key: "footprintLMm", label: "Grundfläche Länge" },
{ key: "footprintWMm", label: "Grundfläche Breite" },
];
interface FormValues {
name: string;
active: boolean;
dimensions: Record<DimensionKey, string>;
}
function initialValues(system: HiveSystemDto | undefined): FormValues {
const text = (key: DimensionKey) => (system ? String(system[key]) : "");
return {
name: system?.name ?? "",
active: system?.active ?? true,
dimensions: {
frameWidthMm: text("frameWidthMm"),
frameHeightMm: text("frameHeightMm"),
footprintLMm: text("footprintLMm"),
footprintWMm: text("footprintWMm"),
},
};
}
function problemOf(values: FormValues): string | null {
if (values.name.trim() === "") return "Bitte einen Namen eingeben.";
const valid = DIMENSIONS.every(({ key }) => {
const value = Number(values.dimensions[key]);
return values.dimensions[key] !== "" && Number.isInteger(value) && value >= 1 && value <= MAX_DIMENSION_MM;
});
return valid ? null : `Alle Maße müssen ganze Millimeter zwischen 1 und ${MAX_DIMENSION_MM} sein.`;
}
function inputFrom(values: FormValues): HiveSystemInput {
return {
name: values.name.trim(),
frameWidthMm: Number(values.dimensions.frameWidthMm),
frameHeightMm: Number(values.dimensions.frameHeightMm),
footprintLMm: Number(values.dimensions.footprintLMm),
footprintWMm: Number(values.dimensions.footprintWMm),
};
}
/** Only what differs from the stored system. */
function patchFrom(values: FormValues, system: HiveSystemDto): HiveSystemPatch {
const input = inputFrom(values);
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (value !== system[key as keyof HiveSystemInput]) patch[key] = value;
}
if (values.active !== system.active) patch.active = values.active;
return patch as HiveSystemPatch;
}
function SystemForm({ system, onSaved }: { system: HiveSystemDto | undefined; onSaved: () => void }) {
const queryClient = useQueryClient();
const [values, setValues] = useState(() => initialValues(system));
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async () => {
if (!system) return createHiveSystem(inputFrom(values));
const patch = patchFrom(values, system);
// Nothing changed: no request needed.
if (Object.keys(patch).length > 0) await updateHiveSystem(system.id, patch);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
onSaved();
},
onError: (mutationError: unknown) =>
setError(serverMessage(mutationError, "Das System konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const problem = problemOf(values);
setError(problem);
if (problem === null) mutation.mutate();
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field label="Name" required>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
maxLength={MAX_NAME_LENGTH}
value={values.name}
onChange={(event) => setValues({ ...values, name: event.target.value })}
/>
)}
</Field>
{DIMENSIONS.map(({ key, label }) => (
<Field key={key} label={label} required>
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.dimensions[key]}
onChange={(value) =>
setValues({ ...values, dimensions: { ...values.dimensions, [key]: value } })
}
min={1}
max={MAX_DIMENSION_MM}
step={DIMENSION_STEP_MM}
unitLabel="mm"
decrementLabel={`${label} verringern`}
incrementLabel={`${label} erhöhen`}
/>
)}
</Field>
))}
{system ? (
<label className="flex min-h-11 items-center gap-3 text-sm" htmlFor="hive-system-active">
<input
id="hive-system-active"
type="checkbox"
className="size-4.5"
checked={values.active}
onChange={(event) => setValues({ ...values, active: event.target.checked })}
/>
Aktiv – ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl
</label>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
);
}
interface SystemDialogProps {
/** Without a system the dialog creates a new one. */
system?: HiveSystemDto;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SystemDialog({ system, open, onOpenChange }: SystemDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{system ? "System bearbeiten" : "System hinzufügen"}</DialogTitle>
</DialogHeader>
{/* Mounted only while open, so every opening starts from the stored values. */}
{open ? <SystemForm system={system} onSaved={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
);
}
@@ -43,6 +43,18 @@ describe("SettingsLayout", () => {
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
});
it("lets a user with only hive-systems:manage into the settings area", () => {
mockUser = { permissions: ["hive-systems:manage"] };
render(
<SettingsLayout>
<p>Einstellungen-Inhalt</p>
</SettingsLayout>,
);
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
});
it("redirects to the dashboard when the user lacks every settings-area permission", () => {
mockUser = { permissions: ["colonies:read"] };
@@ -19,6 +19,7 @@ const SETTINGS_AREA_PERMISSIONS = [
"backup:configure",
"calendar:configure",
"api-keys:manage",
"hive-systems:manage",
"system:read",
];
@@ -91,7 +91,7 @@ export default function NewTreatmentPage() {
const [values, setValues] = useState<TreatmentFormValues>(() =>
initialTreatmentValues(searchParams.get("colonyId") ?? ""),
initialTreatmentValues(searchParams.get("colonyId") ?? "", searchParams),
);
const [dirty, setDirty] = useState(false);
const [medicineNote, setMedicineNote] = useState<string | null>(null);
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { initialTreatmentValues } from "./treatment-form-values";
const params = (query: string) => new URLSearchParams(query);
describe("initialTreatmentValues prefill", () => {
it("keeps the defaults without search params", () => {
expect(initialTreatmentValues("c1", params("colonyId=c1"))).toMatchObject({
colonyId: "c1",
amount: "",
});
});
it("reads date and a millilitre amount", () => {
const values = initialTreatmentValues("c1", params("date=2026-10-12&amount=250&unit=ml"));
expect(values).toMatchObject({ date: "2026-10-12", amount: "250 ml" });
});
it("needs the unit ml for the amount", () => {
expect(initialTreatmentValues("c1", params("amount=250")).amount).toBe("");
expect(initialTreatmentValues("c1", params("amount=250&unit=l")).amount).toBe("");
});
it.each([["date=2026-13-01"], ["date=x"], ["amount=-5&unit=ml"], ["amount=abc&unit=ml"]])(
"ignores the invalid value in %s",
(query) => {
const defaults = initialTreatmentValues("c1");
const values = initialTreatmentValues("c1", params(query));
expect(values.date).toBe(defaults.date);
expect(values.amount).toBe("");
},
);
});
@@ -3,6 +3,7 @@ import type { BulkCreateTreatmentInput } from "@/lib/bulk/api";
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { prefillAmount, prefillDay, type ParamSource } from "@/lib/forms/prefill-params";
import { parsedDose } from "@/lib/treatments/dose";
/** Everything the form holds, as plain values that survive a draft save. */
@@ -34,13 +35,18 @@ export interface TreatmentFormValues {
prescribingVet: string;
}
export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
/**
* Start values. Search params `date`, `amount` and `unit=ml` (the hive card's
* „Als Behandlung eintragen“ link) prefill the fields; invalid ones are ignored.
*/
export function initialTreatmentValues(colonyId: string, params?: ParamSource): TreatmentFormValues {
const amount = params?.get("unit") === "ml" ? prefillAmount(params.get("amount")) : null;
return {
colonyId,
target: "single",
bulkApiaryId: null,
bulkColonyIds: [],
date: todayLocalIsoDate(),
date: (params && prefillDay(params.get("date"))) ?? todayLocalIsoDate(),
startDate: "",
endDate: "",
product: "",
@@ -48,7 +54,7 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
medicineLotId: "",
doseQuantity: "",
activeIngredient: "",
amount: "",
amount: amount ? `${amount} ml` : "",
method: "",
reason: "",
batchNumber: "",
+20
View File
@@ -41,6 +41,12 @@
--color-condition-weak-foreground: var(--condition-weak-foreground);
--color-condition-critical: var(--condition-critical);
--color-condition-critical-foreground: var(--condition-critical-foreground);
--color-holo-bg: var(--holo-bg);
--color-holo-edge: var(--holo-edge);
--color-holo-edge-strong: var(--holo-edge-strong);
--color-holo-fill: var(--holo-fill);
--color-holo-label-bg: var(--holo-label-bg);
--color-holo-label-fg: var(--holo-label-fg);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -126,6 +132,13 @@
--gradient-sky: linear-gradient(135deg, oklch(0.68 0.1 235) 0%, oklch(0.5 0.1 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
}
.dark {
@@ -179,6 +192,13 @@
--gradient-sky: linear-gradient(135deg, oklch(0.55 0.09 235) 0%, oklch(0.34 0.09 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0 0 0 / 30%), 0 8px 24px -8px oklch(0 0 0 / 45%);
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
}
/* ---------------------------------------------------------------------------