- Teile aus der Palette in die Szene ziehen: Stapel öffnet sich, erlaubte Plätze leuchten, Vorschau rastet ein; erlaubte Plätze per applyHiveOps auf einer Kopie (gleiche Regeln wie der Server) - Vorhandene Teile verschieben, auf „Entfernen“ ziehen; Einsätze aus offenen Zargen ziehen, dünne Teile mit Mindest-Trefferhöhe - Drücken auf ein Teil dreht das Modell nie (Prüfung erst beim Dreh- Beginn, passend zur Ereignisfolge von R3F); Abbruch mit Esc, Fenster- wechsel oder Maus ohne Taste - Zoom 0,6–2,5 per Knöpfen (Fokus bleibt an den Grenzen), Rad und Pinch - Verdunster verschieben fragt nicht mehr nach Behandlungsende; Ansage „… verschoben“; gemeinsame Helfer fresh-part und feeder-slots - Neuzeichnen nur bei Zeigerbewegung, geteilte Materialien Gates: Frontend lint, typecheck, 2804 Tests, build; 3D-Chunk 292 kB gzip. Review (Opus) und Browserprüfung Maus/Touch. Refs #21 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
"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 { freeFeederStarts } from "@/lib/hive/feeder-slots";
|
|
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 { frameFeederOps } from "@/lib/hive/feeder-slots";
|
|
import { FrameFeederPicker } from "./frame-feeder-picker";
|
|
import type { FrameFeederChoice } from "./frame-feeder-picker";
|
|
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" },
|
|
];
|
|
// Inserts the sheet can remove, with the noun used in its buttons.
|
|
const REMOVABLE_NOUNS: Partial<Record<HivePartKind, string>> = {
|
|
EVAPORATOR: "Verdunster",
|
|
FRAME_FEEDER: "Futtertasche",
|
|
};
|
|
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;
|
|
/** Opens a box with the frame feeder already chosen (the outline's „Futtertasche einsetzen“ action). */
|
|
startWithFrameFeeder?: 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;
|
|
startWithFrameFeeder: 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, startWithFrameFeeder, 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 frameFeederType = type.kind === "BOX" ? activeTypeOf(catalog, "FRAME_FEEDER") : undefined;
|
|
const feederStarts = frameFeederType ? freeFeederStarts(part, type, frameFeederType, catalog) : [];
|
|
const [feederChoice, setFeederChoice] = useState<FrameFeederChoice>(() => ({
|
|
wants: startWithFrameFeeder && feederStarts.length > 0,
|
|
start: feederStarts[0] ?? 1,
|
|
draft: frameFeederType ? draftOf(frameFeederType, {}) : {},
|
|
}));
|
|
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);
|
|
const removalNoun = REMOVABLE_NOUNS[type.kind] ?? null;
|
|
// Fixed per form, so a retry after an error sends the same ids.
|
|
const [newIds] = useState(() => ({
|
|
floorInsert: newIdempotencyKey(),
|
|
evaporator: newIdempotencyKey(),
|
|
frameFeeder: 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 feederToInsert =
|
|
frameFeederType && feederChoice.wants && feederStarts.includes(feederChoice.start)
|
|
? newPart(newIds.frameFeeder, frameFeederType, feederChoice.draft)
|
|
: null;
|
|
if (frameFeederType && feederToInsert) {
|
|
ops.push(...frameFeederOps(part.id, part.frames ?? [], frameFeederType, feederChoice.start, feederToInsert));
|
|
}
|
|
|
|
const valid =
|
|
areDraftFieldsValid(type, draft) &&
|
|
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft)) &&
|
|
(!frameFeederType || !feederChoice.wants || areDraftFieldsValid(frameFeederType, feederChoice.draft));
|
|
|
|
async function save() {
|
|
// After inserting an evaporator or a frame feeder, focus goes to the new item.
|
|
const outcome = await send(ops);
|
|
if (outcome !== "failed") {
|
|
onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? feederToInsert?.id ?? part.id) : part.id);
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
// An evaporator or frame feeder 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) => {
|
|
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
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}
|
|
{frameFeederType && feederStarts.length > 0 ? (
|
|
<FrameFeederPicker
|
|
boxId={part.id}
|
|
type={frameFeederType}
|
|
frames={part.frames ?? []}
|
|
starts={feederStarts}
|
|
choice={feederChoice}
|
|
onChange={setFeederChoice}
|
|
/>
|
|
) : 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>
|
|
{removalNoun ? (
|
|
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">
|
|
{removalNoun} 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)}
|
|
>
|
|
{removalNoun} 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,
|
|
startWithFrameFeeder = 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}
|
|
startWithFrameFeeder={startWithFrameFeeder}
|
|
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>
|
|
);
|
|
}
|