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,122 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { DocumentCategory } from "@prisma/client";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary } from "@/components/customers/form-ui";
|
||||
import { DocumentPanel } from "@/components/documents/document-panel";
|
||||
import { requirePageContext } from "@/server/api/context";
|
||||
import { can } from "@/server/services/context";
|
||||
import { customerOptions } from "@/server/services/customers/customers";
|
||||
import { customerDisplayName } from "@/server/services/customers/format";
|
||||
import { listDocuments } from "@/server/services/documents/access";
|
||||
import { DOCUMENT_CATEGORIES } from "@/server/services/documents/store";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="documents" />;
|
||||
type SearchParams = Promise<{
|
||||
q?: string;
|
||||
category?: string;
|
||||
customerId?: string;
|
||||
siteId?: string;
|
||||
workOrder?: string;
|
||||
all?: string;
|
||||
page?: string;
|
||||
docOk?: string;
|
||||
docError?: string;
|
||||
docEdit?: string;
|
||||
docVersion?: string;
|
||||
}>;
|
||||
|
||||
/** Backoffice overview of all documents (spec §24) with filters category / customer / site / work order. */
|
||||
export default async function DocumentsPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const ctx = await requirePageContext("documents");
|
||||
if (!can(ctx, "document:read")) notFound();
|
||||
const t = await getTranslations("documents");
|
||||
const sp = await searchParams;
|
||||
|
||||
const q = sp.q?.trim() || undefined;
|
||||
const category = (DOCUMENT_CATEGORIES as string[]).includes(sp.category ?? "") ? (sp.category as DocumentCategory) : undefined;
|
||||
const customerId = sp.customerId?.trim() || undefined;
|
||||
const siteId = sp.siteId?.trim() || undefined;
|
||||
const workOrderNumber = sp.workOrder?.trim() || undefined;
|
||||
const latestOnly = sp.all !== "1";
|
||||
const page = Math.max(1, Math.floor(Number(sp.page)) || 1);
|
||||
|
||||
let workOrderId: string | undefined;
|
||||
if (workOrderNumber) {
|
||||
const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ number: workOrderNumber }, await workOrderScope(ctx)] }, select: { id: true } });
|
||||
workOrderId = wo?.id ?? "__none__";
|
||||
}
|
||||
|
||||
const [result, customers] = await Promise.all([
|
||||
listDocuments(ctx, { q, category, customerId, siteId, workOrderId, latestOnly, page, pageSize: 25 }),
|
||||
can(ctx, "customer:read") ? customerOptions(ctx) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const filters = { q, category, customerId, siteId, workOrder: workOrderNumber, all: latestOnly ? undefined : "1" };
|
||||
const listHref = (p: number) => hrefWith("/documents", { ...filters, page: p > 1 ? p : undefined });
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
<form method="get" action="/documents" className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_auto]" role="search">
|
||||
{siteId && <input type="hidden" name="siteId" value={siteId} />}
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.q")}
|
||||
<input name="q" type="search" defaultValue={q ?? ""} className={controlClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.category")}
|
||||
<select name="category" defaultValue={category ?? ""} className={controlClass}>
|
||||
<option value="">{t("filter.allCategories")}</option>
|
||||
{DOCUMENT_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`category.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.customer")}
|
||||
<select name="customerId" defaultValue={customerId ?? ""} className={controlClass}>
|
||||
<option value="">{t("filter.allCustomers")}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{customerDisplayName(c)} · {c.customerNumber}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.workOrder")}
|
||||
<input name="workOrder" defaultValue={workOrderNumber ?? ""} className={controlClass} />
|
||||
</label>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex min-h-11 items-center gap-2 text-[12.5px] font-semibold">
|
||||
<input type="checkbox" name="all" value="1" defaultChecked={!latestOnly} className="size-4" />
|
||||
{t("versions.older", { count: 2 }).replace(/^\d+\s*/, "")}
|
||||
</label>
|
||||
<button type="submit" className={buttonLinkClass}>
|
||||
{t("filter.apply")}
|
||||
</button>
|
||||
{Object.values(filters).some(Boolean) && (
|
||||
<Link href="/documents" className={buttonLinkClass}>
|
||||
{t("filter.reset")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DocumentPanel ctx={ctx} rows={result.items} baseHref={listHref(page)} links={{}} searchParams={sp} showLinks />
|
||||
|
||||
<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)) }}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user