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>
This commit is contained in:
@@ -1,5 +1,148 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { CustomerForm } from "@/components/customers/customer-form";
|
||||
import { CustomerStatusPill } from "@/components/customers/status";
|
||||
import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary, primaryButtonClass } from "@/components/customers/form-ui";
|
||||
import { createCustomerAction } from "@/server/actions/customers/customers";
|
||||
import { requirePageContext } from "@/server/api/context";
|
||||
import { can } from "@/server/services/context";
|
||||
import { CUSTOMER_LIST_STATUSES, listCustomers, type CustomerListStatus } from "@/server/services/customers/customers";
|
||||
import { customerDisplayName } from "@/server/services/customers/format";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="customers" />;
|
||||
type SearchParams = Promise<{ q?: string; status?: string; page?: string; new?: string }>;
|
||||
|
||||
/** Customer list (spec §7): search, status filter incl. "provisional", 25 per page, create popup. */
|
||||
export default async function CustomersPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const ctx = await requirePageContext("customers");
|
||||
if (!can(ctx, "customer:read")) notFound();
|
||||
const [t, tc] = await Promise.all([getTranslations("customers"), getTranslations("common")]);
|
||||
const sp = await searchParams;
|
||||
|
||||
const status: CustomerListStatus | "all" = (CUSTOMER_LIST_STATUSES as readonly string[]).includes(sp.status ?? "")
|
||||
? (sp.status as CustomerListStatus)
|
||||
: "all";
|
||||
const q = sp.q?.trim() || undefined;
|
||||
const page = Math.max(1, Math.floor(Number(sp.page)) || 1);
|
||||
const result = await listCustomers(ctx, { q, status, page, pageSize: 25 });
|
||||
const canWrite = can(ctx, "customer:write");
|
||||
|
||||
const listHref = (p: number) => hrefWith("/customers", { q, status: status === "all" ? undefined : status, page: p > 1 ? p : undefined });
|
||||
const closeHref = listHref(page);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Link href={hrefWith("/customers", { q, status: status === "all" ? undefined : status, page: page > 1 ? page : undefined, new: 1 })} className={primaryButtonClass}>
|
||||
<Plus className="size-4" aria-hidden /> {t("new")}
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<form method="get" action="/customers" className="mb-4 flex flex-wrap items-end gap-2" role="search">
|
||||
<label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("searchLabel")}
|
||||
<input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.status")}
|
||||
<select name="status" defaultValue={status} className={controlClass}>
|
||||
<option value="all">{t("filter.all")}</option>
|
||||
{CUSTOMER_LIST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className={buttonLinkClass}>
|
||||
{t("filter.apply")}
|
||||
</button>
|
||||
{(q || status !== "all") && (
|
||||
<Link href="/customers" className={buttonLinkClass}>
|
||||
{t("filter.reset")}
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.number")}</TableHead>
|
||||
<TableHead>{t("columns.name")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("columns.city")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t("columns.contact")}</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">{t("columns.sites")}</TableHead>
|
||||
<TableHead>{t("columns.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.items.map((c) => {
|
||||
const href = `/customers/${c.id}`;
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="p-0">
|
||||
<Link href={href} className="block px-3 py-3 font-mono text-[12.5px]">
|
||||
{c.customerNumber ?? "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="p-0">
|
||||
<Link href={href} className="block px-3 py-3 font-semibold">
|
||||
{customerDisplayName(c) || "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden p-0 md:table-cell">
|
||||
<Link href={href} className="block px-3 py-3 text-muted-foreground">
|
||||
{[c.postalCode, c.city].filter(Boolean).join(" ") || "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden p-0 text-[12.5px] lg:table-cell">
|
||||
<Link href={href} className="block px-3 py-3 text-muted-foreground">
|
||||
{c.phone || c.email || "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">{c._count.sites}</TableCell>
|
||||
<TableCell>
|
||||
<CustomerStatusPill status={c.status} label={t(`status.${c.status}`)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{result.items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-6 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
total={result.total}
|
||||
hrefFor={listHref}
|
||||
labels={{ prev: t("pagination.prev"), next: t("pagination.next"), summary: t("pagination.summary", paginationSummary(result.page, result.pageSize, result.total)) }}
|
||||
/>
|
||||
|
||||
{canWrite && sp.new && (
|
||||
<Modal title={t("form.createTitle")} sub={t("form.createSub")} closeHref={closeHref} closeLabel={tc("close")}>
|
||||
<CustomerForm mode="create" action={createCustomerAction} closeHref={closeHref} />
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user