Files
craftvia/src/components/customers/form-ui.tsx
T
msolarczekandClaude Opus 5 d18f4fe431 L1 Stammdaten: Backoffice-Seiten Kunden, Objekte, Teams und Dokumente
Listen mit Suche/Filter/Paginierung, Popups für Anlage und Bearbeitung,
Kundendetail mit Tabs, Dublettenhinweis und Zusammenführen, Objektdetail mit
Kartenlink, Dokumenten-Tab und Historie, Teamverwaltung mit Mitgliedern,
Dokumentenübersicht. Texte in messages de/en, Audit-Label Ansprechpartner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:26:27 +02:00

173 lines
6.6 KiB
TypeScript

import Link from "next/link";
import { cn } from "@/lib/utils";
/**
* Shared, server-safe building blocks of the master-data screens (customers, sites, teams,
* documents). Controls are 44 px high (Brandbook §12.2 touch targets); colors only via tokens.
*/
export const controlClass =
"h-11 w-full min-w-0 rounded-lg border border-input bg-card px-3 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 disabled:opacity-60";
export const textareaClass =
"min-h-24 w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive";
export const buttonLinkClass =
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-background px-4 font-heading text-sm font-semibold transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 outline-none";
export const primaryButtonClass =
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-sm font-semibold text-[var(--ui-accent-foreground)] transition-opacity hover:opacity-90 focus-visible:ring-3 focus-visible:ring-ring/50 outline-none disabled:opacity-60";
export function Field({
id,
label,
error,
hint,
required,
className,
children,
}: {
id: string;
label: string;
error?: string;
hint?: string;
required?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<label htmlFor={id} className="text-[12.5px] font-semibold text-foreground">
{label}
{required && <span aria-hidden className="text-[var(--risk)]"> *</span>}
</label>
{children}
{hint && !error && <p className="text-[12px] text-muted-foreground">{hint}</p>}
{error && (
<p id={`${id}-error`} role="alert" className="text-[12px] font-semibold text-[var(--risk)]">
{error}
</p>
)}
</div>
);
}
export function FormSection({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
return (
<fieldset className={cn("grid gap-3 sm:grid-cols-2", className)}>
<legend className="mb-2 font-heading text-[13px] font-semibold tracking-wide text-muted-foreground uppercase">{title}</legend>
{children}
</fieldset>
);
}
export function TabNav({ tabs, active, label }: { tabs: { key: string; label: string; href: string; count?: number }[]; active: string; label: string }) {
return (
<nav aria-label={label} className="mb-4 flex gap-1 overflow-x-auto border-b">
{tabs.map((tab) => {
const isActive = tab.key === active;
return (
<Link
key={tab.key}
href={tab.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"-mb-px inline-flex min-h-11 items-center gap-1.5 border-b-2 px-3 text-[13.5px] font-semibold whitespace-nowrap transition-colors",
isActive ? "border-[var(--ui-accent)] text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
{typeof tab.count === "number" && (
<span className="rounded-full bg-muted px-1.5 text-[11px] font-bold text-muted-foreground">{tab.count}</span>
)}
</Link>
);
})}
</nav>
);
}
export function Pagination({
page,
pageSize,
total,
hrefFor,
labels,
}: {
page: number;
pageSize: number;
total: number;
hrefFor: (page: number) => string;
labels: { prev: string; next: string; summary: string };
}) {
if (total <= pageSize && page === 1) return total > 0 ? <p className="mt-3 text-[12.5px] text-muted-foreground">{labels.summary}</p> : null;
const last = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-[12.5px] text-muted-foreground">{labels.summary}</p>
<div className="flex gap-2">
{page > 1 ? (
<Link href={hrefFor(page - 1)} className={buttonLinkClass} rel="prev">
{labels.prev}
</Link>
) : (
<span aria-disabled className={cn(buttonLinkClass, "pointer-events-none opacity-50")}>{labels.prev}</span>
)}
{page < last ? (
<Link href={hrefFor(page + 1)} className={buttonLinkClass} rel="next">
{labels.next}
</Link>
) : (
<span aria-disabled className={cn(buttonLinkClass, "pointer-events-none opacity-50")}>{labels.next}</span>
)}
</div>
</div>
);
}
export function Banner({ tone, children }: { tone: "ok" | "warn" | "risk" | "info"; children: React.ReactNode }) {
const tones = {
ok: "border-[var(--ok)] text-[var(--ok)]",
warn: "border-[var(--warn)] text-[var(--warn)]",
risk: "border-[var(--risk)] text-[var(--risk)]",
info: "border-[var(--info)] text-[var(--info)]",
};
return (
<div role={tone === "risk" ? "alert" : "status"} className={cn("mb-4 rounded-lg border-l-4 bg-card px-4 py-3 text-[13px] font-semibold", tones[tone])}>
{children}
</div>
);
}
/** Label/value list for read views. */
export function DefinitionList({ items }: { items: { label: string; value: React.ReactNode }[] }) {
return (
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{items.map((it) => (
<div key={it.label} className="min-w-0">
<dt className="text-[12px] font-semibold text-muted-foreground">{it.label}</dt>
<dd className="mt-0.5 text-sm break-words whitespace-pre-line">{it.value || "—"}</dd>
</div>
))}
</dl>
);
}
export function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("shadow-card rounded-xl border bg-card p-5", className)}>{children}</div>;
}
export function paginationSummary(page: number, pageSize: number, total: number) {
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(total, page * pageSize);
return { from, to, total };
}
/** Build `path?…` from params, dropping empty values. */
export function hrefWith(path: string, params: Record<string, string | number | undefined | null>): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
const qs = sp.toString();
return qs ? `${path}?${qs}` : path;
}