"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> = { 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; /** 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 = {}; 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(() => ({ wants: startWithFrameFeeder && feederStarts.length > 0, start: feederStarts[0] ?? 1, draft: frameFeederType ? draftOf(frameFeederType, {}) : {}, })); const [draft, setDraft] = useState(() => draftOf(type, part.state)); const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined); const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined); const [evaporatorDraft, setEvaporatorDraft] = useState(() => evaporatorType ? draftOf(evaporatorType, {}) : {}, ); const [confirmingRemoval, setConfirmingRemoval] = useState(false); const questionRef = useRef(null); const removeButtonRef = useRef(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 (
{ // 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) => ( setDraft((current) => ({ ...current, [field.key]: value }))} /> ))} {floorInsertType || currentFloorInsert ? (
Bausperre
) : null} {evaporatorType ? (
Verdunster {wantsEvaporator ? evaporatorType.stateSchema.map((field) => ( setEvaporatorDraft((current) => ({ ...current, [field.key]: value }))} /> )) : null}
) : null} {frameFeederType && feederStarts.length > 0 ? ( ) : null} {error ? (

{error}

) : null} {removalNoun ? ( confirmingRemoval ? (

{removalNoun} wirklich entfernen?

) : ( ) ) : null}
); } /** * 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(null); return ( { outlineRef.current?.focusPart(focusAfterSave.current ?? partId); focusAfterSave.current = null; return false; }} > {located && type ? ( { focusAfterSave.current = id; onOpenChange(false); }} /> ) : (

Dieses Teil gibt es nicht mehr.

)}
); }