- 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]>
134 lines
4.6 KiB
TypeScript
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} />
|
|
);
|
|
}
|