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
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import {
|
||||
areDraftFieldsValid,
|
||||
draftOf,
|
||||
draftValue,
|
||||
isFieldChanged,
|
||||
StateFieldControl,
|
||||
} from "./state-field-control";
|
||||
import type { FieldDraft } from "./state-field-control";
|
||||
import { useSheetSubmit } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
const INSERT_OPTIONS = [
|
||||
{ value: true, label: "drin" },
|
||||
{ value: false, label: "draußen" },
|
||||
];
|
||||
const EVAPORATOR_OPTIONS = [
|
||||
{ value: false, label: "Kein Verdunster" },
|
||||
{ value: true, label: "Verdunster einsetzen" },
|
||||
];
|
||||
|
||||
export interface PartSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
partId: string;
|
||||
/** Sends the ops (source CARD); rejects on failure. */
|
||||
submit: SubmitOps;
|
||||
/** On close, focus returns to the edited part's item in the outline. */
|
||||
outlineRef: RefObject<HiveOutlineHandle | null>;
|
||||
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
|
||||
startWithEvaporator?: boolean;
|
||||
}
|
||||
|
||||
interface Located {
|
||||
part: HivePart;
|
||||
/** The box or floor an insert sits in. */
|
||||
host: HivePart | null;
|
||||
}
|
||||
|
||||
function locate(state: HiveState, partId: string): Located | null {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === partId) return { part, host: null };
|
||||
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
|
||||
if (insert) return { part: insert, host: part };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function activeTypeOf(catalog: HiveCatalog, kind: HivePartKind): CatalogPartType | undefined {
|
||||
return [...catalog.partTypes.values()].find((type) => type.kind === kind && type.active);
|
||||
}
|
||||
|
||||
function insertOfKind(part: HivePart, catalog: HiveCatalog, kind: HivePartKind): HivePart | undefined {
|
||||
return (part.inserts ?? []).find((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === kind);
|
||||
}
|
||||
|
||||
function newPart(id: string, type: CatalogPartType, draft: FieldDraft): HivePart {
|
||||
const state: Record<string, number | string> = {};
|
||||
for (const field of type.stateSchema) state[field.key] = draftValue(field, draft[field.key] ?? "");
|
||||
return { id, partTypeId: type.id, state };
|
||||
}
|
||||
|
||||
interface PartFormProps {
|
||||
located: Located;
|
||||
type: CatalogPartType;
|
||||
catalog: HiveCatalog;
|
||||
startWithEvaporator: boolean;
|
||||
submit: SubmitOps;
|
||||
/** `focusId` is the part whose item should get focus once the sheet is closed. */
|
||||
onDone: (focusId: string) => void;
|
||||
}
|
||||
|
||||
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
|
||||
const { part, host } = located;
|
||||
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
|
||||
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
|
||||
const evaporatorType =
|
||||
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
|
||||
|
||||
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
|
||||
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
|
||||
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
|
||||
const [evaporatorDraft, setEvaporatorDraft] = useState<FieldDraft>(() =>
|
||||
evaporatorType ? draftOf(evaporatorType, {}) : {},
|
||||
);
|
||||
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
||||
const questionRef = useRef<HTMLParagraphElement>(null);
|
||||
const removeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const wasConfirming = useRef(false);
|
||||
|
||||
// Keyboard and screen reader users follow the confirmation, and get back to the button on cancel.
|
||||
useEffect(() => {
|
||||
if (confirmingRemoval) questionRef.current?.focus();
|
||||
else if (wasConfirming.current) removeButtonRef.current?.focus();
|
||||
wasConfirming.current = confirmingRemoval;
|
||||
}, [confirmingRemoval]);
|
||||
const { send, pending, error } = useSheetSubmit(submit);
|
||||
// Fixed per form, so a retry after an error sends the same ids.
|
||||
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
|
||||
|
||||
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
|
||||
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
|
||||
? [{ op: "setState" as const, id: part.id, key: field.key, value: draftValue(field, draft[field.key]) }]
|
||||
: [],
|
||||
);
|
||||
const ops: HiveOp[] = [...changedStateOps];
|
||||
if (floorInsertType && wantsFloorInsert && !currentFloorInsert) {
|
||||
ops.push({ op: "addInsert", hostId: part.id, part: newPart(newIds.floorInsert, floorInsertType, {}) });
|
||||
} else if (currentFloorInsert && !wantsFloorInsert) {
|
||||
ops.push({ op: "removePart", id: currentFloorInsert.id });
|
||||
}
|
||||
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
|
||||
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
|
||||
|
||||
const valid =
|
||||
areDraftFieldsValid(type, draft) &&
|
||||
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
|
||||
|
||||
async function save() {
|
||||
// After inserting an evaporator, focus goes to the new item.
|
||||
const outcome = await send(ops);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
// An evaporator is an insert: focus goes to its box afterwards.
|
||||
const outcome = await send([{ op: "removePart", id: part.id }]);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 px-4 pb-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (ops.length > 0 && valid && !pending) void save();
|
||||
}}
|
||||
>
|
||||
{type.stateSchema.map((field) => (
|
||||
<StateFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
idPrefix={`part-${part.id}`}
|
||||
value={draft[field.key] ?? ""}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, [field.key]: value }))}
|
||||
/>
|
||||
))}
|
||||
{floorInsertType || currentFloorInsert ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={`part-${part.id}-insert`} className="text-sm font-medium">
|
||||
Bausperre
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={INSERT_OPTIONS}
|
||||
value={wantsFloorInsert}
|
||||
onChange={setWantsFloorInsert}
|
||||
labelledBy={`part-${part.id}-insert`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{evaporatorType ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span id={`part-${part.id}-evaporator`} className="text-sm font-medium">
|
||||
Verdunster
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={EVAPORATOR_OPTIONS}
|
||||
value={wantsEvaporator}
|
||||
onChange={setWantsEvaporator}
|
||||
labelledBy={`part-${part.id}-evaporator`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
{wantsEvaporator
|
||||
? evaporatorType.stateSchema.map((field) => (
|
||||
<StateFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
idPrefix={`part-${part.id}-new-evaporator`}
|
||||
value={evaporatorDraft[field.key] ?? ""}
|
||||
onChange={(value) => setEvaporatorDraft((current) => ({ ...current, [field.key]: value }))}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SheetFooter className="p-0">
|
||||
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
{type.kind === "EVAPORATOR" ? (
|
||||
confirmingRemoval ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={`part-${part.id}-confirm`}
|
||||
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
|
||||
>
|
||||
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
|
||||
Verdunster wirklich entfernen?
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
|
||||
Ja, entfernen
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => setConfirmingRemoval(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
ref={removeButtonRef}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
onClick={() => setConfirmingRemoval(true)}
|
||||
>
|
||||
Verdunster entfernen
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</SheetFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit one part: a control per state field, the floor's Bausperre, a box's
|
||||
* evaporator (spec 9.4). Everything changed goes out as one batch of ops.
|
||||
*/
|
||||
export function PartSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
state,
|
||||
catalog,
|
||||
partId,
|
||||
submit,
|
||||
outlineRef,
|
||||
startWithEvaporator = false,
|
||||
}: PartSheetProps) {
|
||||
const located = locate(state, partId);
|
||||
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
|
||||
// Set by a save that changes what should get focus (a new evaporator, the box of a removed one).
|
||||
const focusAfterSave = useRef<string | null>(null);
|
||||
|
||||
return (
|
||||
<HiveSheetShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={partDisplayName(state, catalog, partId)}
|
||||
finalFocus={() => {
|
||||
outlineRef.current?.focusPart(focusAfterSave.current ?? partId);
|
||||
focusAfterSave.current = null;
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{located && type ? (
|
||||
<PartForm
|
||||
key={partId}
|
||||
located={located}
|
||||
type={type}
|
||||
catalog={catalog}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
submit={submit}
|
||||
onDone={(id) => {
|
||||
focusAfterSave.current = id;
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
|
||||
Dieses Teil gibt es nicht mehr.
|
||||
</p>
|
||||
)}
|
||||
</HiveSheetShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user