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:
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: "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user