Merge lane/stammdaten in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:38:34 +02:00
co-authored by Claude Opus 5
64 changed files with 7249 additions and 73 deletions
+1
View File
@@ -59,6 +59,7 @@ const ENTITY_LABEL: Record<string, string> = {
platformAdmin: "Plattform-Admin",
// Craftvia-Fachobjekte (Labels vorab, Module folgen)
customer: "Kunde",
contact: "Ansprechpartner",
site: "Objekt",
team: "Team",
work_order: "Auftrag",
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { ActionState } from "@/server/api/action-state";
import { buttonLinkClass, primaryButtonClass } from "@/components/customers/form-ui";
export type FormAction = (prev: ActionState, fd: FormData) => Promise<ActionState>;
export const IDLE_STATE: ActionState = { status: "idle" };
/** Translate an action error: specific reason first, then the generic code. */
export function useErrorText(namespace: string) {
const t = useTranslations(namespace);
return (state: ActionState): string | null => {
if (state.status !== "error") return null;
if (state.reason && t.has(`errors.${state.reason}`)) return t(`errors.${state.reason}`);
return t(`errors.${state.code}`);
};
}
/** Field error text (reason code → message, otherwise "invalidField"). */
export function useFieldError(namespace: string) {
const t = useTranslations(namespace);
return (state: ActionState, field: string): string | undefined => {
if (state.status !== "error" || !state.fieldErrors?.[field]) return undefined;
const code = state.fieldErrors[field];
return t.has(`errors.${code}`) ? t(`errors.${code}`) : t("errors.invalidField");
};
}
export function FormError({ namespace, state }: { namespace: string; state: ActionState }) {
const text = useErrorText(namespace)(state);
if (!text) return null;
return (
<p role="alert" className="rounded-lg border-l-4 border-[var(--risk)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
{text}
</p>
);
}
/** One-button form for simple mutations (confirm, delete …) with optional browser confirmation. */
export function ActionButtonForm({
action,
label,
pendingLabel,
confirmText,
namespace,
tone = "outline",
successHref,
className,
}: {
action: FormAction;
label: string;
pendingLabel?: string;
confirmText?: string;
namespace: string;
tone?: "primary" | "outline" | "danger";
successHref?: string;
className?: string;
}) {
const router = useRouter();
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
const errorText = useErrorText(namespace)(state);
useEffect(() => {
if (state.status === "ok") {
if (successHref) router.push(successHref);
else router.refresh();
}
}, [state, successHref, router]);
return (
<form
action={formAction}
className={cn("flex flex-col gap-1", className)}
onSubmit={(e) => {
if (confirmText && !window.confirm(confirmText)) e.preventDefault();
}}
>
<button
type="submit"
disabled={pending}
className={cn(
tone === "primary" ? primaryButtonClass : buttonLinkClass,
tone === "danger" && "border-[var(--risk)] text-[var(--risk)]",
)}
>
{pending && pendingLabel ? pendingLabel : label}
</button>
{errorText && (
<span role="alert" className="text-[12px] font-semibold text-[var(--risk)]">
{errorText}
</span>
)}
</form>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type { ActionState } from "@/server/api/action-state";
import { buttonLinkClass, controlClass, Field, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
type Values = Record<string, string | null | undefined>;
export function ContactForm({ action, initial = {}, closeHref }: { action: FormAction; initial?: Values; closeHref: string }) {
const t = useTranslations("customers");
const router = useRouter();
const fieldError = useFieldError("customers");
const [values, setValues] = useState<Values>(initial);
const [state, formAction, pending] = useActionState<ActionState, FormData>(async (prev, fd) => {
setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
return action(prev, fd);
}, IDLE_STATE);
useEffect(() => {
if (state.status === "ok") router.push(closeHref);
}, [state, closeHref, router]);
const input = (name: string, type = "text", required = false) => (
<Field id={`ct-${name}`} label={t(`contacts.fields.${name}`)} required={required} error={fieldError(state, name)}>
<input
id={`ct-${name}`}
name={name}
type={type}
required={required}
defaultValue={values[name] ?? ""}
aria-invalid={fieldError(state, name) ? true : undefined}
className={controlClass}
/>
</Field>
);
return (
<form action={formAction} className="grid gap-3 p-5 sm:grid-cols-2">
{input("name", "text", true)}
{input("role")}
{input("phone", "tel")}
{input("mobile", "tel")}
{input("email", "email")}
<Field id="ct-preferredChannel" label={t("contacts.fields.preferredChannel")} error={fieldError(state, "preferredChannel")}>
<select id="ct-preferredChannel" name="preferredChannel" defaultValue={values.preferredChannel ?? ""} className={controlClass}>
<option value="">{t("contacts.channel.none")}</option>
{(["phone", "mobile", "email"] as const).map((c) => (
<option key={c} value={c}>
{t(`contacts.channel.${c}`)}
</option>
))}
</select>
</Field>
<Field id="ct-notes" label={t("contacts.fields.notes")} className="sm:col-span-2">
<textarea id="ct-notes" name="notes" defaultValue={values.notes ?? ""} className={textareaClass} />
</Field>
<div className="sm:col-span-2">
<FormError namespace="customers" state={state} />
</div>
<div className="flex flex-wrap gap-2 border-t pt-4 sm:col-span-2">
<button type="submit" disabled={pending} className={primaryButtonClass}>
{pending ? t("form.saving") : t("form.save")}
</button>
<Link href={closeHref} className={buttonLinkClass}>
{t("form.cancel")}
</Link>
</div>
</form>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type { CustomerFormState } from "@/server/actions/customers/customers";
import type { ActionState } from "@/server/api/action-state";
import { Pill } from "@/components/mockup-ui";
import { buttonLinkClass, controlClass, Field, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
import { FormError, useFieldError } from "@/components/customers/action-form";
type Values = Record<string, string | null | undefined>;
type CreateAction = (prev: CustomerFormState, fd: FormData) => Promise<CustomerFormState>;
const STATUSES = ["active", "inactive", "provisional"] as const;
export function CustomerForm({
mode,
action,
initial = {},
closeHref,
}: {
mode: "create" | "edit";
action: CreateAction | ((prev: ActionState, fd: FormData) => Promise<ActionState>);
initial?: Values;
closeHref: string;
}) {
const t = useTranslations("customers");
const router = useRouter();
const fieldError = useFieldError("customers");
const [values, setValues] = useState<Values>(initial);
const [state, formAction, pending] = useActionState<CustomerFormState, FormData>(async (prev, fd) => {
setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
return (action as CreateAction)(prev, fd);
}, { status: "idle" });
useEffect(() => {
if (state.status === "ok") router.push(closeHref);
}, [state, closeHref, router]);
const plain: ActionState = state.status === "duplicates" ? { status: "idle" } : state;
const err = (name: string) => fieldError(plain, name);
const text = (name: string, opts: { required?: boolean; type?: string; autoComplete?: string; className?: string; hint?: string } = {}) => (
<Field id={`c-${name}`} label={t(`fields.${name}`)} required={opts.required} error={err(name)} hint={opts.hint} className={opts.className}>
<input
id={`c-${name}`}
name={name}
type={opts.type ?? "text"}
autoComplete={opts.autoComplete ?? "off"}
defaultValue={values[name] ?? ""}
aria-invalid={err(name) ? true : undefined}
aria-describedby={err(name) ? `c-${name}-error` : undefined}
className={controlClass}
/>
</Field>
);
return (
<form action={formAction} className="space-y-5 p-5">
{state.status === "duplicates" && (
<div role="alert" className="rounded-xl border-l-4 border-[var(--warn)] bg-[var(--surface-soft)] p-4">
<p className="font-heading text-sm font-semibold text-[var(--warn)]">{t("duplicates.title")}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t("duplicates.hint")}</p>
<ul className="mt-3 space-y-2">
{state.candidates.map((c) => (
<li key={c.customerId} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border bg-card p-3">
<div className="min-w-0">
<p className="text-sm font-semibold">
{c.displayName} <span className="text-muted-foreground">· {c.customerNumber}</span>
{c.city && <span className="text-muted-foreground"> · {c.city}</span>}
</p>
<p className="mt-1 flex flex-wrap gap-1.5 text-[12px]">
<Pill tone="warn">{t("duplicates.score", { percent: Math.round(c.score * 100) })}</Pill>
{c.reasons.map((r) => (
<Pill key={r} tone="mut">{t(`duplicates.reasons.${r}`)}</Pill>
))}
</p>
</div>
<Link href={`/customers/${c.customerId}`} className={buttonLinkClass}>
{t("duplicates.open")}
</Link>
</li>
))}
</ul>
<input type="hidden" name="acknowledgeDuplicates" value="1" />
</div>
)}
<FormSection title={t("sections.customer")}>
{text("customerNumber", { hint: mode === "create" ? t("fields.customerNumberHint") : undefined })}
<Field id="c-status" label={t("fields.status")} error={err("status")}>
<select id="c-status" name="status" defaultValue={values.status ?? "active"} className={controlClass}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t(`status.${s}`)}
</option>
))}
</select>
</Field>
{text("companyName", { className: "sm:col-span-2", autoComplete: "organization" })}
{text("salutation", { autoComplete: "honorific-prefix" })}
<div className="hidden sm:block" />
{text("firstName", { autoComplete: "given-name" })}
{text("lastName", { autoComplete: "family-name" })}
</FormSection>
<FormSection title={t("sections.address")}>
{text("street", { autoComplete: "address-line1" })}
{text("houseNumber")}
{text("postalCode", { autoComplete: "postal-code" })}
{text("city", { autoComplete: "address-level2" })}
{text("country", { hint: "DE, AT, CH …" })}
</FormSection>
<FormSection title={t("sections.contact")}>
{text("phone", { type: "tel", autoComplete: "tel" })}
{text("mobile", { type: "tel" })}
{text("email", { type: "email", autoComplete: "email", className: "sm:col-span-2" })}
</FormSection>
<FormSection title={t("sections.notes")}>
{(["notes", "billingNotes"] as const).map((name) => (
<Field key={name} id={`c-${name}`} label={t(`fields.${name}`)} error={err(name)} className="sm:col-span-2">
<textarea id={`c-${name}`} name={name} defaultValue={values[name] ?? ""} className={textareaClass} />
</Field>
))}
</FormSection>
<FormError namespace="customers" state={plain} />
<div className="flex flex-wrap gap-2 border-t pt-4">
<button type="submit" disabled={pending} className={primaryButtonClass}>
{pending ? t("form.saving") : state.status === "duplicates" ? t("duplicates.createAnyway") : mode === "create" ? t("form.create") : t("form.save")}
</button>
<Link href={closeHref} className={buttonLinkClass}>
{t("form.cancel")}
</Link>
</div>
</form>
);
}
+172
View File
@@ -0,0 +1,172 @@
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;
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { useTranslations } from "next-intl";
import { Pill } from "@/components/mockup-ui";
import { buttonLinkClass, controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
export type MergeCandidateView = {
customerId: string;
displayName: string;
customerNumber: string | null;
city: string | null;
score: number;
reasons: string[];
};
export function MergeForm({
action,
source,
candidates,
closeHref,
}: {
action: FormAction;
source: { displayName: string; customerNumber: string | null };
candidates: MergeCandidateView[];
closeHref: string;
}) {
const t = useTranslations("customers");
const fieldError = useFieldError("customers");
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
const confirmError = state.status === "error" && state.fieldErrors?.confirm ? t("errors.confirm_required") : undefined;
return (
<form action={formAction} className="space-y-4 p-5">
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
<p className="text-[12px] font-semibold text-muted-foreground">{t("merge.source")}</p>
<p className="text-sm font-semibold">
{source.displayName} <span className="text-muted-foreground">· {source.customerNumber}</span>
</p>
</div>
<fieldset>
<legend className="mb-2 text-[12.5px] font-semibold">{t("merge.candidates")}</legend>
{candidates.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t("merge.noCandidates")}</p>
) : (
<div className="space-y-2">
{candidates.map((c) => (
<label key={c.customerId} className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border bg-card p-3 has-[:checked]:border-[var(--ui-accent)]">
<input type="radio" name="targetId" value={c.customerId} className="mt-1 size-4" />
<span className="min-w-0">
<span className="block text-sm font-semibold">
{c.displayName} <span className="text-muted-foreground">· {c.customerNumber}</span>
{c.city && <span className="text-muted-foreground"> · {c.city}</span>}
</span>
<span className="mt-1 flex flex-wrap gap-1.5">
<Pill tone="warn">{t("duplicates.score", { percent: Math.round(c.score * 100) })}</Pill>
{c.reasons.map((r) => (
<Pill key={r} tone="mut">{t(`duplicates.reasons.${r}`)}</Pill>
))}
</span>
</span>
</label>
))}
</div>
)}
{fieldError(state, "targetId") && <p role="alert" className="mt-1 text-[12px] font-semibold text-[var(--risk)]">{fieldError(state, "targetId")}</p>}
</fieldset>
<Field id="m-targetNumber" label={t("merge.targetNumber")} error={fieldError(state, "targetNumber")}>
<input id="m-targetNumber" name="targetNumber" className={controlClass} autoComplete="off" />
</Field>
<label className="flex min-h-11 items-start gap-3 rounded-lg border border-[var(--warn)] bg-card p-3 text-[13px]">
<input type="checkbox" name="confirm" className="mt-0.5 size-4" aria-invalid={confirmError ? true : undefined} />
<span>{t("merge.confirm")}</span>
</label>
{confirmError && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{confirmError}</p>}
<FormError namespace="customers" state={state} />
<div className="flex flex-wrap gap-2 border-t pt-4">
<button type="submit" disabled={pending} className={primaryButtonClass}>
{pending ? t("form.saving") : t("merge.submit")}
</button>
<Link href={closeHref} className={buttonLinkClass}>
{t("form.cancel")}
</Link>
</div>
</form>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { Pill } from "@/components/mockup-ui";
import { STATUS_GROUP, STATUS_GROUP_TONE, type WorkOrderStatus } from "@/lib/work-orders/status";
// Status pills always carry text (Brandbook §11.4: color is never the only signal).
const CUSTOMER_TONE = { active: "ok", inactive: "mut", provisional: "warn", merged: "info" } as const;
const SITE_TONE = { active: "ok", inactive: "mut", provisional: "warn" } as const;
const TEAM_TONE = { active: "ok", inactive: "mut" } as const;
export function CustomerStatusPill({ status, label }: { status: keyof typeof CUSTOMER_TONE; label: string }) {
return <Pill tone={CUSTOMER_TONE[status]}>{label}</Pill>;
}
export function SiteStatusPill({ status, label }: { status: keyof typeof SITE_TONE; label: string }) {
return <Pill tone={SITE_TONE[status]}>{label}</Pill>;
}
export function TeamStatusPill({ status, label }: { status: keyof typeof TEAM_TONE; label: string }) {
return <Pill tone={TEAM_TONE[status]}>{label}</Pill>;
}
const GROUP_PILL = { neutral: "mut", info: "info", accent: "orange", warning: "warn", success: "ok", danger: "risk" } as const;
/** Work order status as Brandbook status group; `label` comes from messages sites.statusGroup.<group>. */
export function orderStatusGroup(status: WorkOrderStatus) {
return STATUS_GROUP[status];
}
export function OrderStatusPill({ status, label }: { status: WorkOrderStatus; label: string }) {
return <Pill tone={GROUP_PILL[STATUS_GROUP_TONE[STATUS_GROUP[status]]]}>{label}</Pill>;
}
@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { buttonLinkClass, controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
import { FormError, IDLE_STATE, type FormAction } from "@/components/customers/action-form";
export function DocumentEditForm({
action,
initial,
categories,
visibilities,
closeHref,
}: {
action: FormAction;
initial: { title: string; category: string; visibility: string };
categories: string[];
visibilities: string[];
closeHref: string;
}) {
const t = useTranslations("documents");
const router = useRouter();
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
useEffect(() => {
if (state.status === "ok") router.push(closeHref);
}, [state, closeHref, router]);
return (
<form action={formAction} className="grid gap-3 p-5 sm:grid-cols-2">
<Field id="d-title" label={t("edit.titleField")} className="sm:col-span-2">
<input id="d-title" name="title" defaultValue={initial.title} className={controlClass} />
</Field>
<Field id="d-category" label={t("upload.category")}>
<select id="d-category" name="category" defaultValue={initial.category} className={controlClass}>
{categories.map((c) => (
<option key={c} value={c}>
{t(`category.${c}`)}
</option>
))}
</select>
</Field>
<Field id="d-visibility" label={t("upload.visibility")}>
<select id="d-visibility" name="visibility" defaultValue={initial.visibility} className={controlClass}>
{visibilities.map((v) => (
<option key={v} value={v}>
{t(`visibility.${v}`)}
</option>
))}
</select>
</Field>
<div className="sm:col-span-2">
<FormError namespace="documents" state={state} />
</div>
<div className="flex flex-wrap gap-2 border-t pt-4 sm:col-span-2">
<button type="submit" disabled={pending} className={primaryButtonClass}>
{pending ? t("edit.saving") : t("edit.save")}
</button>
<Link href={closeHref} className={buttonLinkClass}>
{t("edit.cancel")}
</Link>
</div>
</form>
);
}
@@ -0,0 +1,86 @@
import { getTranslations } from "next-intl/server";
import { Modal } from "@/components/modal";
import { Card } from "@/components/customers/form-ui";
import { DocumentTable, type DocumentRow } from "@/components/documents/document-table";
import { DocumentUploadForm, UploadFeedback } from "@/components/documents/document-upload-form";
import { DocumentEditForm } from "@/components/documents/document-edit-form";
import { updateDocumentAction } from "@/server/actions/documents/documents";
import { can, type ServiceCtx } from "@/server/services/context";
import { DOCUMENT_CATEGORIES } from "@/server/services/documents/store";
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
/**
* Documents tab for customer/site detail pages: upload (new document or new version), grouped
* list with versions, edit popup. Search params: docOk, docError, docEdit, docVersion.
*/
export async function DocumentPanel({
ctx,
rows,
baseHref,
links,
searchParams,
categories = DOCUMENT_CATEGORIES,
defaultCategory,
showLinks = false,
}: {
ctx: ServiceCtx;
rows: DocumentRow[];
baseHref: string;
links: { customerId?: string; siteId?: string };
searchParams: { docOk?: string; docError?: string; docEdit?: string; docVersion?: string };
categories?: readonly string[];
defaultCategory?: string;
showLinks?: boolean;
}) {
const t = await getTranslations("documents");
const tc = await getTranslations("common");
const canWrite = can(ctx, "document:write");
const visibilities = allowedDocumentVisibility(ctx);
const editDoc = searchParams.docEdit ? rows.find((r) => r.id === searchParams.docEdit) : undefined;
const versionOf = searchParams.docVersion ? rows.find((r) => r.lineageId === searchParams.docVersion) : undefined;
return (
<div className="space-y-4">
<UploadFeedback ok={searchParams.docOk} error={searchParams.docError} />
{canWrite && (
<Card>
<DocumentUploadForm
heading={t("upload.title")}
returnTo={baseHref}
links={links}
categories={[...categories]}
visibilities={visibilities}
defaultCategory={defaultCategory}
/>
</Card>
)}
<DocumentTable rows={rows} baseHref={baseHref} canWrite={canWrite} showLinks={showLinks} />
{canWrite && editDoc && (
<Modal title={t("edit.title")} sub={editDoc.fileName} closeHref={baseHref} closeLabel={tc("close")}>
<DocumentEditForm
action={updateDocumentAction.bind(null, editDoc.id, baseHref)}
initial={{ title: editDoc.title ?? "", category: editDoc.category, visibility: editDoc.visibility }}
categories={[...DOCUMENT_CATEGORIES]}
visibilities={visibilities}
closeHref={baseHref}
/>
</Modal>
)}
{canWrite && versionOf && (
<Modal title={t("upload.newVersionTitle")} sub={t("upload.newVersionOf", { name: versionOf.title || versionOf.fileName })} closeHref={baseHref} closeLabel={tc("close")}>
<div className="p-5">
<DocumentUploadForm
returnTo={baseHref}
lineageId={versionOf.lineageId}
categories={[...DOCUMENT_CATEGORIES]}
visibilities={visibilities}
defaultCategory={versionOf.category}
defaultVisibility={versionOf.visibility}
/>
</div>
</Modal>
)}
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import Link from "next/link";
import { Download, FileText, Image as ImageIcon, Mic } from "lucide-react";
import { getFormatter, getTranslations } from "next-intl/server";
import { Pill } from "@/components/mockup-ui";
import { ActionButtonForm } from "@/components/customers/action-form";
import { buttonLinkClass } from "@/components/customers/form-ui";
import { deleteDocumentAction } from "@/server/actions/documents/documents";
import { documentHref } from "@/server/services/documents/access";
import { customerDisplayName } from "@/server/services/customers/format";
export type DocumentRow = {
id: string;
title: string | null;
fileName: string;
category: string;
visibility: string;
mimeType: string;
fileSize: number;
checksum: string;
version: number;
lineageId: string;
createdAt: Date;
customer: { id: string; customerNumber: string | null; companyName: string | null; firstName: string | null; lastName: string | null } | null;
site: { id: string; name: string } | null;
workOrder: { id: string; number: string; title: string } | null;
};
const VISIBILITY_TONE = { backoffice_only: "risk", team_lead: "warn", team: "info", customer_report: "ok" } as const;
function fileIcon(mime: string) {
if (mime.startsWith("image/")) return ImageIcon;
if (mime.startsWith("audio/")) return Mic;
return FileText;
}
/**
* Document list. `groupVersions` shows the newest version per lineage and lists older versions
* underneath (spec §24.2). Edit/new-version links open popups on `baseHref` (?docEdit / ?docVersion).
*/
export async function DocumentTable({
rows,
baseHref,
canWrite,
groupVersions = true,
showLinks = true,
}: {
rows: DocumentRow[];
baseHref: string;
canWrite: boolean;
groupVersions?: boolean;
showLinks?: boolean;
}) {
const t = await getTranslations("documents");
const format = await getFormatter();
if (rows.length === 0) return <p className="text-[13px] text-muted-foreground">{t("empty")}</p>;
const sep = baseHref.includes("?") ? "&" : "?";
const groups = new Map<string, DocumentRow[]>();
for (const r of rows) {
const key = groupVersions ? r.lineageId : r.id;
groups.set(key, [...(groups.get(key) ?? []), r]);
}
const size = (bytes: number) =>
bytes >= 1024 * 1024 ? `${format.number(bytes / 1024 / 1024, { maximumFractionDigits: 1 })} MB` : `${format.number(Math.max(1, Math.round(bytes / 1024)))} KB`;
return (
<ul className="divide-y rounded-xl border bg-card">
{[...groups.values()].map((versions) => {
const sorted = [...versions].sort((a, b) => b.version - a.version);
const doc = sorted[0];
const older = sorted.slice(1);
const Icon = fileIcon(doc.mimeType);
return (
<li key={doc.id} className="p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 gap-3">
<Icon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<a href={documentHref(doc.id)} className="font-semibold break-words hover:underline">
{doc.title || doc.fileName}
</a>
<p className="mt-0.5 text-[12px] text-muted-foreground">
{doc.title ? `${doc.fileName} · ` : ""}
{t("versions.label", { version: doc.version })} · {size(doc.fileSize)} · {format.dateTime(doc.createdAt, { dateStyle: "medium", timeStyle: "short" })}
</p>
<p className="mt-1.5 flex flex-wrap gap-1.5">
<Pill tone="mut">{t(`category.${doc.category}`)}</Pill>
<Pill tone={VISIBILITY_TONE[doc.visibility as keyof typeof VISIBILITY_TONE] ?? "mut"}>{t(`visibility.${doc.visibility}`)}</Pill>
</p>
{showLinks && (doc.customer || doc.site || doc.workOrder) && (
<p className="mt-1.5 text-[12px] text-muted-foreground">
{doc.customer && (
<>
{t("link.customer")}:{" "}
<Link className="font-semibold hover:underline" href={`/customers/${doc.customer.id}`}>
{customerDisplayName(doc.customer)}
</Link>{" "}
</>
)}
{doc.site && (
<>
{t("link.site")}:{" "}
<Link className="font-semibold hover:underline" href={`/sites/${doc.site.id}`}>
{doc.site.name}
</Link>{" "}
</>
)}
{doc.workOrder && (
<>
{t("link.workOrder")}:{" "}
<Link className="font-semibold hover:underline" href={`/work-orders/${doc.workOrder.id}`}>
{doc.workOrder.number}
</Link>
</>
)}
</p>
)}
<p className="mt-1 font-mono text-[11px] break-all text-muted-foreground" title="SHA-256">
{doc.checksum.slice(0, 16)}…
</p>
</div>
</div>
<div className="flex flex-wrap items-start gap-2">
<a href={documentHref(doc.id)} className={buttonLinkClass}>
<Download className="size-4" aria-hidden /> {t("actions.download")}
</a>
{canWrite && (
<>
<Link href={`${baseHref}${sep}docEdit=${doc.id}`} className={buttonLinkClass}>
{t("actions.edit")}
</Link>
<Link href={`${baseHref}${sep}docVersion=${doc.lineageId}`} className={buttonLinkClass}>
{t("upload.newVersion")}
</Link>
<ActionButtonForm
action={deleteDocumentAction.bind(null, doc.id, baseHref)}
label={t("actions.delete")}
confirmText={t("actions.deleteConfirm")}
namespace="documents"
tone="danger"
/>
</>
)}
</div>
</div>
{older.length > 0 && (
<details className="mt-3 ml-8">
<summary className="min-h-11 cursor-pointer text-[12.5px] font-semibold text-muted-foreground">{t("versions.older", { count: older.length })}</summary>
<ul className="mt-1 space-y-1">
{older.map((o) => (
<li key={o.id} className="flex flex-wrap items-center gap-2 text-[12.5px]">
<a href={documentHref(o.id)} className="inline-flex min-h-11 items-center font-semibold hover:underline">
{t("versions.label", { version: o.version })} · {o.fileName}
</a>
<span className="text-muted-foreground">
{size(o.fileSize)} · {format.dateTime(o.createdAt, { dateStyle: "medium" })}
</span>
</li>
))}
</ul>
</details>
)}
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,94 @@
import { getTranslations } from "next-intl/server";
import { controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
/**
* Plain multipart form (works without JavaScript) posting to /documents/upload, which redirects
* back to `returnTo` with ?docOk=1 or ?docError=<reason> (see UploadFeedback).
*/
export async function DocumentUploadForm({
returnTo,
links = {},
lineageId,
categories,
visibilities,
defaultCategory = "other",
defaultVisibility = "team",
heading,
}: {
returnTo: string;
links?: { customerId?: string; siteId?: string; workOrderId?: string };
lineageId?: string;
categories: string[];
visibilities: string[];
defaultCategory?: string;
defaultVisibility?: string;
heading?: string;
}) {
const t = await getTranslations("documents");
const idp = lineageId ? `v-${lineageId.slice(0, 6)}` : "up";
return (
<form action="/documents/upload" method="post" encType="multipart/form-data" className="grid gap-3 sm:grid-cols-2">
{heading && <p className="font-heading text-sm font-semibold sm:col-span-2">{heading}</p>}
<input type="hidden" name="returnTo" value={returnTo} />
{links.customerId && <input type="hidden" name="customerId" value={links.customerId} />}
{links.siteId && <input type="hidden" name="siteId" value={links.siteId} />}
{links.workOrderId && <input type="hidden" name="workOrderId" value={links.workOrderId} />}
{lineageId && <input type="hidden" name="lineageId" value={lineageId} />}
<Field id={`${idp}-file`} label={t("upload.file")} required hint={t("upload.fileHint")} className="sm:col-span-2">
<input
id={`${idp}-file`}
name="file"
type="file"
required
accept="application/pdf,image/jpeg,image/png,image/webp,image/heic,audio/*"
className={`${controlClass} py-2 file:mr-3 file:font-semibold`}
/>
</Field>
<Field id={`${idp}-title`} label={t("upload.titleField")} className="sm:col-span-2">
<input id={`${idp}-title`} name="title" className={controlClass} />
</Field>
<Field id={`${idp}-category`} label={t("upload.category")} required>
<select id={`${idp}-category`} name="category" defaultValue={defaultCategory} className={controlClass}>
{categories.map((c) => (
<option key={c} value={c}>
{t(`category.${c}`)}
</option>
))}
</select>
</Field>
<Field id={`${idp}-visibility`} label={t("upload.visibility")} required>
<select id={`${idp}-visibility`} name="visibility" defaultValue={defaultVisibility} className={controlClass}>
{visibilities.map((v) => (
<option key={v} value={v}>
{t(`visibility.${v}`)}
</option>
))}
</select>
</Field>
<div className="sm:col-span-2">
<button type="submit" className={primaryButtonClass}>
{t("upload.submit")}
</button>
</div>
</form>
);
}
/** Result banner of the upload redirect. */
export async function UploadFeedback({ ok, error }: { ok?: string; error?: string }) {
if (!ok && !error) return null;
const t = await getTranslations("documents");
if (ok) {
return (
<p role="status" className="mb-3 rounded-lg border-l-4 border-[var(--ok)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--ok)]">
{t("upload.ok")}
</p>
);
}
const key = t.has(`uploadErrors.${error}`) ? `uploadErrors.${error}` : "uploadErrors.generic";
return (
<p role="alert" className="mb-3 rounded-lg border-l-4 border-[var(--risk)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
{t(key)}
</p>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type { ActionState } from "@/server/api/action-state";
import { buttonLinkClass, controlClass, Field, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
type Values = Record<string, string | null | undefined>;
type Option = { id: string; label: string };
const STATUSES = ["active", "inactive", "provisional"] as const;
export function SiteForm({
mode,
action,
initial = {},
customers,
contacts,
closeHref,
}: {
mode: "create" | "edit";
action: FormAction;
initial?: Values;
customers: Option[];
/** Contacts of the (fixed) customer; null = customer not yet known. */
contacts: Option[] | null;
closeHref: string;
}) {
const t = useTranslations("sites");
const router = useRouter();
const fieldError = useFieldError("sites");
const [values, setValues] = useState<Values>(initial);
const [state, formAction, pending] = useActionState<ActionState, FormData>(async (prev, fd) => {
setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
return action(prev, fd);
}, IDLE_STATE);
useEffect(() => {
if (state.status === "ok") router.push(closeHref);
}, [state, closeHref, router]);
const err = (name: string) => fieldError(state, name);
const input = (name: string, opts: { required?: boolean; type?: string; className?: string; inputMode?: "decimal" } = {}) => (
<Field id={`s-${name}`} label={t(`fields.${name}`)} required={opts.required} error={err(name)} className={opts.className}>
<input
id={`s-${name}`}
name={name}
type={opts.type ?? "text"}
inputMode={opts.inputMode}
required={opts.required}
defaultValue={values[name] ?? ""}
aria-invalid={err(name) ? true : undefined}
className={controlClass}
/>
</Field>
);
const area = (name: string) => (
<Field id={`s-${name}`} label={t(`fields.${name}`)} error={err(name)} className="sm:col-span-2">
<textarea id={`s-${name}`} name={name} defaultValue={values[name] ?? ""} className={textareaClass} />
</Field>
);
return (
<form action={formAction} className="space-y-5 p-5">
<FormSection title={t("sections.base")}>
<Field id="s-customerId" label={t("fields.customerId")} required error={err("customerId")} className="sm:col-span-2">
<select id="s-customerId" name="customerId" required defaultValue={values.customerId ?? ""} className={controlClass}>
<option value="" disabled>
{t("fields.selectCustomer")}
</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</Field>
{input("name", { required: true })}
<Field id="s-status" label={t("fields.status")} error={err("status")}>
<select id="s-status" name="status" defaultValue={values.status ?? "active"} className={controlClass}>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t(`status.${s}`)}
</option>
))}
</select>
</Field>
</FormSection>
<FormSection title={t("sections.address")}>
{input("street")}
{input("houseNumber")}
{input("postalCode")}
{input("city")}
{input("country")}
</FormSection>
<FormSection title={t("sections.onSite")}>
{contacts ? (
<Field id="s-contactId" label={t("fields.contactId")} error={err("contactId")}>
<select id="s-contactId" name="contactId" defaultValue={values.contactId ?? ""} className={controlClass}>
<option value="">{t("fields.noContact")}</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</Field>
) : (
<p className="self-end text-[12px] text-muted-foreground">{t("form.contactHint")}</p>
)}
{input("onSiteContact")}
{input("phone", { type: "tel" })}
</FormSection>
<FormSection title={t("sections.notes")}>
{area("accessNotes")}
{area("parkingNotes")}
{area("safetyNotes")}
{area("technicalNotes")}
</FormSection>
<FormSection title={t("sections.geo")}>
{input("latitude", { inputMode: "decimal" })}
{input("longitude", { inputMode: "decimal" })}
</FormSection>
<FormError namespace="sites" state={state} />
<div className="flex flex-wrap gap-2 border-t pt-4">
<button type="submit" disabled={pending} className={primaryButtonClass}>
{pending ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
</button>
<Link href={closeHref} className={buttonLinkClass}>
{t("form.cancel")}
</Link>
</div>
</form>
);
}
+140
View File
@@ -0,0 +1,140 @@
import Link from "next/link";
import { AlertTriangle, Camera, CheckCircle2, FileText, MinusCircle, Siren } from "lucide-react";
import { getFormatter, getTranslations } from "next-intl/server";
import type { SiteHistoryEntry } from "@/server/services/sites/history";
import { OrderStatusPill, orderStatusGroup } from "@/components/customers/status";
/**
* Site history list (spec §8.3): newest first; open follow-up work is highlighted with a warning
* border, icon and text (never color alone). Also reused read-only by the field lane.
*/
export async function SiteHistory({ entries, linkOrders = true }: { entries: SiteHistoryEntry[]; linkOrders?: boolean }) {
const t = await getTranslations("sites");
const format = await getFormatter();
if (entries.length === 0) return <p className="text-[13px] text-muted-foreground">{t("history.empty")}</p>;
return (
<ol className="space-y-3">
{entries.map((e) => (
<li
key={e.workOrderId}
className={
e.hasOpenFollowUp
? "shadow-card rounded-xl border border-l-4 border-l-[var(--warn)] bg-card p-4"
: "shadow-card rounded-xl border bg-card p-4"
}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[12px] font-semibold text-muted-foreground">
<time dateTime={e.date.toISOString()}>{format.dateTime(e.date, { dateStyle: "medium" })}</time>
{" · "}
{e.orderType ?? "—"}
{" · "}
{e.team ?? t("history.noTeam")}
</p>
<p className="mt-0.5 font-heading text-[15px] font-semibold">
{linkOrders ? (
<Link href={`/work-orders/${e.workOrderId}`} className="hover:underline">
{e.number} · {e.title}
</Link>
) : (
<>
{e.number} · {e.title}
</>
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{e.isEmergency && (
<span className="inline-flex items-center gap-1 text-[12px] font-semibold text-[var(--risk)]">
<Siren className="size-3.5" aria-hidden /> {t("history.emergency")}
</span>
)}
<OrderStatusPill status={e.status} label={t(`statusGroup.${orderStatusGroup(e.status)}`)} />
</div>
</div>
{e.hasOpenFollowUp && (
<div className="mt-3 flex gap-2 rounded-lg bg-[var(--surface-soft)] p-3 text-[13px]">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-[var(--warn)]" aria-hidden />
<div>
<p className="font-semibold text-[var(--warn)]">{t("history.followUp")}</p>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{e.followUps.map((f, i) => (
<li key={i} className="whitespace-pre-line">{f}</li>
))}
</ul>
</div>
</div>
)}
<div className="mt-3 grid gap-4 md:grid-cols-3">
<section>
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.workDone")}</h4>
{e.workDone.length ? (
<ul className="mt-1 space-y-1 text-[13px]">
{e.workDone.map((w, i) => (
<li key={i} className="whitespace-pre-line">{w}</li>
))}
</ul>
) : (
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noWorkDone")}</p>
)}
</section>
<section>
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.materials")}</h4>
{e.materials.length ? (
<ul className="mt-1 space-y-0.5 text-[13px]">
{e.materials.map((m) => (
<li key={`${m.name}|${m.unit}`}>
{format.number(m.quantity, { maximumFractionDigits: 3 })} {m.unit} · {m.name}
</li>
))}
</ul>
) : (
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noMaterials")}</p>
)}
</section>
<section>
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.reports")}</h4>
{e.approvedReports.length ? (
<ul className="mt-1 space-y-1 text-[13px]">
{e.approvedReports.map((r) => (
<li key={r.id}>
<Link href={`/reports/${r.id}`} className="inline-flex min-h-8 items-center gap-1.5 font-semibold hover:underline">
<FileText className="size-3.5" aria-hidden />
{t("history.reportLink", {
type: t(`history.reportType.${r.type}`),
version: r.version,
date: format.dateTime(r.reportDate, { dateStyle: "medium" }),
})}
</Link>
</li>
))}
</ul>
) : (
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noReports")}</p>
)}
<p className="mt-2 flex items-center gap-1.5 text-[13px]">
<Camera className="size-3.5 text-muted-foreground" aria-hidden /> {t("history.photos", { count: e.photoCount })}
</p>
<p className="mt-1 flex items-center gap-1.5 text-[13px]">
{e.signed ? (
<>
<CheckCircle2 className="size-3.5 text-[var(--ok)]" aria-hidden /> {t("history.signed")}
</>
) : (
<>
<MinusCircle className="size-3.5 text-muted-foreground" aria-hidden /> {t("history.notSigned")}
</>
)}
</p>
</section>
</div>
</li>
))}
</ol>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { ActionState } from "@/server/api/action-state";
import { buttonLinkClass, controlClass, Field, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
export type TeamFormValues = {
name?: string | null;
leaderUserId?: string | null;
status?: "active" | "inactive";
phone?: string | null;
vehicle?: string | null;
area?: string | null;
notes?: string | null;
members: { userId: string; validFrom: string; validTo: string }[];
};
type Row = { key: number; userId: string; validFrom: string; validTo: string };
export function TeamForm({
mode,
action,
initial,
users,
closeHref,
}: {
mode: "create" | "edit";
action: FormAction;
initial: TeamFormValues;
users: { id: string; name: string; email: string }[];
closeHref: string;
}) {
const t = useTranslations("teams");
const router = useRouter();
const fieldError = useFieldError("teams");
const nextKey = useRef(initial.members.length);
const [rows, setRows] = useState<Row[]>(initial.members.map((m, i) => ({ key: i, ...m })));
const [values, setValues] = useState<Record<string, string>>(
Object.fromEntries(Object.entries(initial).filter(([k]) => k !== "members").map(([k, v]) => [k, String(v ?? "")])),
);
const [state, formAction, pending] = useActionState<ActionState, FormData>(async (prev, fd) => {
setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
return action(prev, fd);
}, IDLE_STATE);
useEffect(() => {
if (state.status === "ok") router.push(closeHref);
}, [state, closeHref, router]);
const err = (name: string) => fieldError(state, name);
const today = new Date().toISOString().slice(0, 10);
const input = (name: string, type = "text", required = false) => (
<Field id={`t-${name}`} label={t(`fields.${name}`)} required={required} error={err(name)}>
<input id={`t-${name}`} name={name} type={type} required={required} defaultValue={values[name] ?? ""} aria-invalid={err(name) ? true : undefined} className={controlClass} />
</Field>
);
const updateRow = (key: number, patch: Partial<Row>) => setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...patch } : r)));
return (
<form action={formAction} className="space-y-5 p-5">
<FormSection title={t("form.sub")}>
{input("name", "text", true)}
<Field id="t-status" label={t("fields.status")}>
<select id="t-status" name="status" defaultValue={values.status || "active"} className={controlClass}>
<option value="active">{t("status.active")}</option>
<option value="inactive">{t("status.inactive")}</option>
</select>
</Field>
<Field id="t-leaderUserId" label={t("fields.leaderUserId")} error={err("leaderUserId")}>
<select id="t-leaderUserId" name="leaderUserId" defaultValue={values.leaderUserId ?? ""} className={controlClass}>
<option value="">{t("fields.noLeader")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
))}
</select>
</Field>
{input("phone", "tel")}
{input("vehicle")}
{input("area")}
<Field id="t-notes" label={t("fields.notes")} className="sm:col-span-2">
<textarea id="t-notes" name="notes" defaultValue={values.notes ?? ""} className={textareaClass} />
</Field>
</FormSection>
<fieldset>
<legend className="mb-2 font-heading text-[13px] font-semibold tracking-wide text-muted-foreground uppercase">{t("members.title")}</legend>
{rows.length === 0 && <p className="mb-2 text-[13px] text-muted-foreground">{t("members.empty")}</p>}
<div className="space-y-2">
{rows.map((row, idx) => (
<div key={row.key} className="grid gap-2 rounded-lg border bg-[var(--surface-soft)] p-3 sm:grid-cols-[1fr_10rem_10rem_auto] sm:items-end">
<Field id={`m-user-${row.key}`} label={t("members.user")}>
<select
id={`m-user-${row.key}`}
name="memberUserId"
required
value={row.userId}
onChange={(e) => updateRow(row.key, { userId: e.target.value })}
className={controlClass}
>
<option value="" disabled>
{t("members.selectUser")}
</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<Field id={`m-from-${row.key}`} label={t("members.validFrom")}>
<input id={`m-from-${row.key}`} type="date" name="memberValidFrom" value={row.validFrom} onChange={(e) => updateRow(row.key, { validFrom: e.target.value })} className={controlClass} />
</Field>
<Field id={`m-to-${row.key}`} label={t("members.validTo")} hint={idx === 0 ? t("members.validToHint") : undefined}>
<input id={`m-to-${row.key}`} type="date" name="memberValidTo" value={row.validTo} onChange={(e) => updateRow(row.key, { validTo: e.target.value })} className={controlClass} />
</Field>
<button
type="button"
onClick={() => setRows((rs) => rs.filter((r) => r.key !== row.key))}
className={`${buttonLinkClass} self-end`}
aria-label={`${t("members.remove")}: ${users.find((u) => u.id === row.userId)?.name ?? idx + 1}`}
>
<Trash2 className="size-4" aria-hidden />
<span className="sm:sr-only">{t("members.remove")}</span>
</button>
</div>
))}
</div>
{err("members") && <p role="alert" className="mt-2 text-[12px] font-semibold text-[var(--risk)]">{err("members")}</p>}
<button
type="button"
className={`${buttonLinkClass} mt-2`}
onClick={() => setRows((rs) => [...rs, { key: nextKey.current++, userId: "", validFrom: today, validTo: "" }])}
>
<Plus className="size-4" aria-hidden /> {t("members.add")}
</button>
</fieldset>
<FormError namespace="teams" state={state} />
<div className="flex flex-wrap gap-2 border-t pt-4">
<button type="submit" disabled={pending} className={primaryButtonClass}>
{pending ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
</button>
<Link href={closeHref} className={buttonLinkClass}>
{t("form.cancel")}
</Link>
</div>
</form>
);
}