Files
ApiCore/ApiCore-Frontend/src/components/hive/state-field-control.tsx
T
Nick SzaunigandClaude Opus 5.5 c608f5d405 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]>
2026-10-16 09:12:03 +02:00

134 lines
4.6 KiB
TypeScript

"use client";
import { ChoiceGroup } from "@/components/form/choice-group";
import { NumberStepper } from "@/components/form/number-stepper";
import { LEVEL_TOLERANCE } from "@/lib/hive/types";
import type { CatalogPartType, ChoiceField, LevelField, StateField } from "@/lib/hive/types";
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
/** Digits after the decimal point of a step: 0.5 -> 1, 10 -> 0. */
export function decimalsOf(step: number): number {
const text = String(step);
const dot = text.indexOf(".");
return dot < 0 ? 0 : text.length - dot - 1;
}
/** Draft text per state field key ("2.5" for levels, the option for choices). */
export type FieldDraft = Record<string, string>;
export function draftOf(type: CatalogPartType, state: Record<string, number | string>): FieldDraft {
const draft: FieldDraft = {};
for (const field of type.stateSchema) {
const value = state[field.key];
draft[field.key] = value === undefined ? defaultText(field) : String(value);
}
return draft;
}
function defaultText(field: StateField): string {
return field.kind === "level" ? "0" : (field.options[0] ?? "");
}
/** Empty, out of 0..max or off the step grid: the value must not be saved. */
export function isLevelValid(raw: string, field: LevelField): boolean {
return levelProblem(raw, field) === null && !isOffGrid(raw, field);
}
function isOffGrid(raw: string, field: LevelField): boolean {
const position = Number(raw) / field.step;
return Math.abs(position - Math.round(position)) > LEVEL_TOLERANCE;
}
/** Message for an empty or out-of-range level; the stepper words the grid error itself. */
export function levelProblem(raw: string, field: LevelField): string | null {
const value = raw === "" ? Number.NaN : Number(raw);
if (Number.isNaN(value)) return "Bitte einen Wert angeben.";
if (value < 0 || value > field.max) return `Nur Werte von 0 bis ${numberFormat.format(field.max)}.`;
return null;
}
export function areDraftFieldsValid(type: CatalogPartType, draft: FieldDraft): boolean {
return type.stateSchema.every((field) => field.kind === "choice" || isLevelValid(draft[field.key] ?? "", field));
}
/** The value as the API takes it: a number for levels, the option for choices. */
export function draftValue(field: StateField, raw: string): number | string {
return field.kind === "level" ? Number(raw) : raw;
}
export function isFieldChanged(field: StateField, raw: string, current: number | string | undefined): boolean {
if (field.kind === "choice") return raw !== String(current ?? defaultText(field));
return Math.abs(Number(raw) - Number(current ?? 0)) > LEVEL_TOLERANCE;
}
interface StateFieldControlProps {
field: StateField;
idPrefix: string;
value: string;
onChange: (value: string) => void;
}
function LevelControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: LevelField }) {
const id = `${idPrefix}-${field.key}`;
const problem = levelProblem(value, field);
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={id} className="text-sm font-medium">
{field.label}
</label>
<NumberStepper
id={id}
value={value}
onChange={onChange}
min={0}
max={field.max}
step={field.step}
decimals={decimalsOf(field.step)}
unitLabel={field.unit}
decrementLabel={`${field.label} verringern`}
incrementLabel={`${field.label} erhöhen`}
invalid={problem !== null}
describedBy={problem ? `${id}-problem` : undefined}
/>
{problem ? (
<p id={`${id}-problem`} className="text-xs text-destructive">
{problem}
</p>
) : null}
</div>
);
}
function ChoiceControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: ChoiceField }) {
const id = `${idPrefix}-${field.key}`;
const options = field.options.map((option, index) => ({
value: option,
label: field.optionLabels[index] ?? option,
}));
return (
<div className="flex flex-col gap-1.5">
<span id={`${id}-label`} className="text-sm font-medium">
{field.label}
</span>
<ChoiceGroup
options={options}
value={value}
onChange={onChange}
labelledBy={`${id}-label`}
className="grid-cols-2"
/>
</div>
);
}
/** One control for one `stateSchema` field: a stepper for levels, a choice group for choices. */
export function StateFieldControl(props: StateFieldControlProps) {
const { field } = props;
return field.kind === "level" ? (
<LevelControl {...props} field={field} />
) : (
<ChoiceControl {...props} field={field} />
);
}