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]>
This commit is contained in:
1 parent
0091b3d28b
commit
c608f5d405
87 files changed
+9493
-43
No files matched your search
@@ -0,0 +1,133 @@
|
||||
"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} />
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user