Files
ApiCore/ApiCore-Frontend/src/components/hive/part-sheet.tsx
T
Nick SzaunigandClaude Opus 5.5 45274f4301
Backend Image / build-and-push (push) Successful in 7m57s
Frontend Image / build-and-push (push) Successful in 18m33s
Manifests / validate (push) Successful in 23s
feat: Beute-Editor mit Ziehen und Ablegen in 3D (Beute L4, Task 18)
- 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]>
2026-10-16 10:03:43 +02:00

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>
);
}