+ ) : null
+ }
+ >
+
+
diff --git a/ApiCore-Frontend/src/components/form/draft-banner.tsx b/ApiCore-Frontend/src/components/form/draft-banner.tsx
new file mode 100644
index 0000000..3861a19
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/draft-banner.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { History } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface DraftBannerProps {
+ savedAt: Date;
+ onRestore: () => void;
+ onDiscard: () => void;
+}
+
+/**
+ * Offers an unfinished entry back - never applies it silently, so the form
+ * is always what the person expects to see.
+ */
+export function DraftBanner({
+ savedAt,
+ onRestore,
+ onDiscard,
+}: DraftBannerProps) {
+ const time = savedAt.toLocaleString("de-DE", {
+ dateStyle: "short",
+ timeStyle: "short",
+ });
+ return (
+
+
+
+ Unfertiger Eintrag vom {time} gefunden.
+
+
+
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/form/field.tsx b/ApiCore-Frontend/src/components/form/field.tsx
new file mode 100644
index 0000000..a2d174e
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/field.tsx
@@ -0,0 +1,77 @@
+import { ReactNode, useId } from "react";
+import { cn } from "@/lib/utils";
+
+interface FieldProps {
+ label: string;
+ /** Marks the field "Pflicht"; everything else is visibly "optional". */
+ required?: boolean;
+ hint?: string;
+ error?: string | null;
+ className?: string;
+ /** Receives the ids the control must carry for label, hint and error. */
+ children: (ids: {
+ id: string;
+ labelId: string;
+ describedBy: string | undefined;
+ invalid: boolean;
+ }) => ReactNode;
+}
+
+/**
+ * One form field with everything in one place: label, whether it is needed,
+ * a short hint and - right next to it, not in a toast that vanishes - what
+ * is wrong. Fewer things to keep in mind while filling a form.
+ */
+export function Field({
+ label,
+ required,
+ hint,
+ error,
+ className,
+ children,
+}: FieldProps) {
+ const id = useId();
+ const hintId = hint ? `${id}-hint` : undefined;
+ const errorId = error ? `${id}-error` : undefined;
+ const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
+
+ return (
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/form/sticky-submit.tsx b/ApiCore-Frontend/src/components/form/sticky-submit.tsx
new file mode 100644
index 0000000..65448b2
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/sticky-submit.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from "react";
+
+/**
+ * Keeps the one primary action within thumb reach on a phone, however long
+ * the form is; on wider screens it simply ends the form.
+ */
+export function StickySubmit({
+ children,
+ note,
+}: {
+ children: ReactNode;
+ note?: ReactNode;
+}) {
+ return (
+
+ {note}
+ {children}
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/form/toggle-chip.tsx b/ApiCore-Frontend/src/components/form/toggle-chip.tsx
new file mode 100644
index 0000000..5096094
--- /dev/null
+++ b/ApiCore-Frontend/src/components/form/toggle-chip.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface ToggleChipProps {
+ label: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}
+
+/** A yes/no observation as a large tile; state shown by tick AND colour. */
+export function ToggleChip({ label, checked, onChange }: ToggleChipProps) {
+ return (
+
+ );
+}
diff --git a/ApiCore-Frontend/src/lib/forms/error-message.ts b/ApiCore-Frontend/src/lib/forms/error-message.ts
new file mode 100644
index 0000000..f51a9bf
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/forms/error-message.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "@/lib/auth/api-client";
+
+/**
+ * The server's own explanation when there is one (it names the problem,
+ * e.g. "Code wird bereits verwendet"), otherwise a plain fallback.
+ */
+export function errorMessage(error: unknown, fallback: string): string {
+ if (error instanceof ApiError && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
diff --git a/ApiCore-Frontend/src/lib/forms/use-form-draft.ts b/ApiCore-Frontend/src/lib/forms/use-form-draft.ts
new file mode 100644
index 0000000..148b1b7
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/forms/use-form-draft.ts
@@ -0,0 +1,94 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const PREFIX = "apicore:draft:";
+const SAVE_DELAY_MS = 600;
+// Drafts older than this are stale (the situation at the hive has changed).
+const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
+
+interface StoredDraft {
+ savedAt: number;
+ values: T;
+}
+
+export interface FormDraft {
+ /** A draft from an earlier visit, offered until restored or discarded. */
+ pending: { savedAt: Date; values: T } | null;
+ restore: () => T | null;
+ discard: () => void;
+ /** Call after a successful save. */
+ clear: () => void;
+}
+
+function read(key: string): StoredDraft | null {
+ try {
+ const raw = window.localStorage.getItem(PREFIX + key);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as StoredDraft;
+ if (typeof parsed.savedAt !== "number" || Date.now() - parsed.savedAt > MAX_AGE_MS) {
+ window.localStorage.removeItem(PREFIX + key);
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+function remove(key: string): void {
+ try {
+ window.localStorage.removeItem(PREFIX + key);
+ } catch {
+ // Storage unavailable (private mode) - nothing to remove.
+ }
+}
+
+/**
+ * Keeps an unfinished form on this device: an interruption (a call, a
+ * sting, a dead battery) does not cost the typed-in entry. Saves only once
+ * something was changed (`dirty`), never sends anything anywhere.
+ */
+export function useFormDraft(key: string, values: T, dirty: boolean): FormDraft {
+ const [pending, setPending] = useState["pending"]>(null);
+ const offered = useRef(false);
+
+ useEffect(() => {
+ if (offered.current) return;
+ offered.current = true;
+ const stored = read(key);
+ if (stored) {
+ // Read after mount: localStorage does not exist during server render.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setPending({ savedAt: new Date(stored.savedAt), values: stored.values });
+ }
+ }, [key]);
+
+ useEffect(() => {
+ if (!dirty || pending) return;
+ const timer = window.setTimeout(() => {
+ try {
+ const draft: StoredDraft = { savedAt: Date.now(), values };
+ window.localStorage.setItem(PREFIX + key, JSON.stringify(draft));
+ } catch {
+ // Storage full or blocked - the form still works, just without a draft.
+ }
+ }, SAVE_DELAY_MS);
+ return () => window.clearTimeout(timer);
+ }, [key, values, dirty, pending]);
+
+ const restore = useCallback(() => {
+ const values = pending?.values ?? null;
+ setPending(null);
+ return values;
+ }, [pending]);
+
+ const discard = useCallback(() => {
+ remove(key);
+ setPending(null);
+ }, [key]);
+
+ const clear = useCallback(() => remove(key), [key]);
+
+ return { pending, restore, discard, clear };
+}