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>
173 lines
6.6 KiB
TypeScript
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;
|
|
}
|