Files
craftvia/src/app/(app)/sites/page.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

153 lines
7.2 KiB
TypeScript

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 { SiteForm } from "@/components/sites/site-form";
import { SiteStatusPill } from "@/components/customers/status";
import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary, primaryButtonClass } from "@/components/customers/form-ui";
import { createSiteAction } from "@/server/actions/sites/sites";
import { requirePageContext } from "@/server/api/context";
import { can, ServiceError } from "@/server/services/context";
import { customerOptions, getCustomer } from "@/server/services/customers/customers";
import { customerDisplayName, formatAddress } from "@/server/services/customers/format";
import { listSites, SITE_STATUSES } from "@/server/services/sites/sites";
type SearchParams = Promise<{ q?: string; status?: string; customerId?: string; page?: string; new?: string }>;
/** Site list (spec §8): search, status/customer filter, pagination, create popup. */
export default async function SitesPage({ searchParams }: { searchParams: SearchParams }) {
const ctx = await requirePageContext("sites");
if (!can(ctx, "site:read")) notFound();
const [t, tc] = await Promise.all([getTranslations("sites"), getTranslations("common")]);
const sp = await searchParams;
const status = (SITE_STATUSES as readonly string[]).includes(sp.status ?? "") ? (sp.status as (typeof SITE_STATUSES)[number]) : "all";
const q = sp.q?.trim() || undefined;
const customerId = sp.customerId?.trim() || undefined;
const page = Math.max(1, Math.floor(Number(sp.page)) || 1);
const canWrite = can(ctx, "site:write") && can(ctx, "customer:read");
// with ?new=1 the customerId only preselects the form, it does not filter the list
const filterCustomer = sp.new ? undefined : customerId;
const result = await listSites(ctx, { q, status, customerId: filterCustomer, page, pageSize: 25 });
const listHref = (p: number) => hrefWith("/sites", { q, status: status === "all" ? undefined : status, customerId: filterCustomer, page: p > 1 ? p : undefined });
const closeHref = sp.new && customerId ? `/customers/${customerId}?tab=sites` : listHref(page);
let formCustomers: { id: string; label: string }[] = [];
let contacts: { id: string; label: string }[] | null = null;
if (canWrite && sp.new) {
formCustomers = (await customerOptions(ctx)).map((c) => ({ id: c.id, label: `${customerDisplayName(c)} · ${c.customerNumber ?? ""}${c.city ? ` · ${c.city}` : ""}` }));
if (customerId) {
try {
const customer = await getCustomer(ctx, customerId);
contacts = customer.contacts.map((c) => ({ id: c.id, label: c.role ? `${c.name} (${c.role})` : c.name }));
} catch (err) {
if (!(err instanceof ServiceError)) throw err;
}
}
}
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("/sites", { q, status: status === "all" ? undefined : status, customerId: filterCustomer, new: 1 })} className={primaryButtonClass}>
<Plus className="size-4" aria-hidden /> {t("new")}
</Link>
) : undefined
}
/>
<form method="get" action="/sites" className="mb-4 flex flex-wrap items-end gap-2" role="search">
{filterCustomer && <input type="hidden" name="customerId" value={filterCustomer} />}
<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>
{SITE_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" || filterCustomer) && (
<Link href="/sites" className={buttonLinkClass}>
{t("filter.reset")}
</Link>
)}
</form>
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.name")}</TableHead>
<TableHead className="hidden sm:table-cell">{t("columns.customer")}</TableHead>
<TableHead className="hidden md:table-cell">{t("columns.address")}</TableHead>
<TableHead className="hidden lg:table-cell">{t("columns.orders")}</TableHead>
<TableHead>{t("columns.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.items.map((s) => (
<TableRow key={s.id}>
<TableCell className="p-0">
<Link href={`/sites/${s.id}`} className="block px-3 py-3 font-semibold">
{s.name}
</Link>
</TableCell>
<TableCell className="hidden p-0 sm:table-cell">
<Link href={`/sites/${s.id}`} className="block px-3 py-3 text-muted-foreground">
{customerDisplayName(s.customer)}
</Link>
</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatAddress(s) || "—"}</TableCell>
<TableCell className="hidden lg:table-cell">{s._count.workOrders}</TableCell>
<TableCell>
<SiteStatusPill status={s.status} label={t(`status.${s.status}`)} />
</TableCell>
</TableRow>
))}
{result.items.length === 0 && (
<TableRow>
<TableCell colSpan={5} 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")}>
<SiteForm mode="create" action={createSiteAction} customers={formCustomers} contacts={contacts} initial={{ customerId: customerId ?? "" }} closeHref={closeHref} />
</Modal>
)}
</main>
);
}