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
+353
View File
@@ -0,0 +1,353 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Mail, Phone, Plus, Smartphone } from "lucide-react";
import { getFormatter, getTranslations } from "next-intl/server";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Modal } from "@/components/modal";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ActionButtonForm } from "@/components/customers/action-form";
import { ContactForm } from "@/components/customers/contact-form";
import { CustomerForm } from "@/components/customers/customer-form";
import { MergeForm } from "@/components/customers/merge-form";
import { CustomerStatusPill, OrderStatusPill, orderStatusGroup, SiteStatusPill } from "@/components/customers/status";
import { Banner, buttonLinkClass, Card, DefinitionList, primaryButtonClass, TabNav } from "@/components/customers/form-ui";
import { DocumentPanel } from "@/components/documents/document-panel";
import { confirmCustomerAction, deleteCustomerAction, mergeCustomerAction, updateCustomerAction } from "@/server/actions/customers/customers";
import { createContactAction, deleteContactAction, updateContactAction } from "@/server/actions/customers/contacts";
import { requirePageContext } from "@/server/api/context";
import { can, ServiceError } from "@/server/services/context";
import { getCustomer, listCustomerWorkOrders } from "@/server/services/customers/customers";
import { findDuplicateCustomers } from "@/server/services/customers/duplicates";
import { customerDisplayName, formatAddress } from "@/server/services/customers/format";
import { listDocuments } from "@/server/services/documents/access";
import { listSites } from "@/server/services/sites/sites";
const TABS = ["master", "contacts", "sites", "orders", "documents"] as const;
type Tab = (typeof TABS)[number];
type SearchParams = Promise<{
tab?: string;
edit?: string;
contact?: string;
merge?: string;
delete?: string;
merged?: string;
docOk?: string;
docError?: string;
docEdit?: string;
docVersion?: string;
}>;
function toFormValues(obj: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, v === null || v === undefined ? "" : String(v)]));
}
export default async function CustomerDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: SearchParams }) {
const ctx = await requirePageContext("customers");
const [{ id }, sp] = await Promise.all([params, searchParams]);
const [t, tc, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("common"), getTranslations("sites"), getFormatter()]);
let customer;
try {
customer = await getCustomer(ctx, id);
} catch (err) {
if (err instanceof ServiceError) notFound();
throw err;
}
const tab: Tab = (TABS as readonly string[]).includes(sp.tab ?? "") ? (sp.tab as Tab) : "master";
const base = `/customers/${id}`;
const tabHref = (k: Tab) => (k === "master" ? base : `${base}?tab=${k}`);
const here = tabHref(tab);
const withParam = (k: string, v: string) => `${here}${here.includes("?") ? "&" : "?"}${k}=${encodeURIComponent(v)}`;
const isMerged = customer.status === "merged";
const canWrite = can(ctx, "customer:write") && !isMerged;
const canMerge = can(ctx, "customer:merge") && !isMerged;
const name = customerDisplayName(customer) || "—";
const mergedTarget = isMerged && customer.mergedIntoId
? await ctx.db.customer.findFirst({ where: { id: customer.mergedIntoId }, select: { id: true, companyName: true, firstName: true, lastName: true, customerNumber: true } })
: null;
return (
<main className="flex-1 p-4 sm:p-6">
<Link href="/customers" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
</Link>
<PageHead
crumb={`${t("title")} · ${customer.customerNumber ?? ""}`}
title={name}
sub={formatAddress(customer, { withCountry: true }) || undefined}
actions={
<div className="flex flex-wrap items-center gap-2">
<CustomerStatusPill status={customer.status} label={t(`status.${customer.status}`)} />
{canWrite && customer.status === "provisional" && (
<ActionButtonForm action={confirmCustomerAction.bind(null, id)} label={t("detail.confirm")} tone="primary" namespace="customers" />
)}
{canWrite && (
<Link href={withParam("edit", "1")} className={buttonLinkClass}>
{t("detail.edit")}
</Link>
)}
{canMerge && (
<Link href={withParam("merge", "1")} className={buttonLinkClass}>
{t("detail.merge")}
</Link>
)}
{canWrite && (
<Link href={withParam("delete", "1")} className={buttonLinkClass}>
{t("detail.delete")}
</Link>
)}
</div>
}
/>
{sp.merged && <Banner tone="ok">{t("detail.mergedOk")}</Banner>}
{customer.status === "provisional" && <Banner tone="warn">{t("provisionalHint")}</Banner>}
{mergedTarget && (
<Banner tone="info">
{t("detail.mergedInto")}{" "}
<Link href={`/customers/${mergedTarget.id}`} className="underline">
{customerDisplayName(mergedTarget)} ({mergedTarget.customerNumber})
</Link>
</Banner>
)}
<TabNav
label={t("title")}
active={tab}
tabs={TABS.map((k) => ({ key: k, label: t(`tabs.${k}`), href: tabHref(k), count: k === "contacts" ? customer.contacts.length : undefined }))}
/>
{tab === "master" && (
<Card>
<DefinitionList
items={[
{ label: t("fields.customerNumber"), value: customer.customerNumber },
{ label: t("fields.status"), value: t(`status.${customer.status}`) },
{ label: t("fields.companyName"), value: customer.companyName },
{ label: `${t("fields.salutation")} / ${t("fields.firstName")} / ${t("fields.lastName")}`, value: [customer.salutation, customer.firstName, customer.lastName].filter(Boolean).join(" ") },
{ label: t("sections.address"), value: formatAddress(customer) },
{ label: t("fields.country"), value: customer.country },
{ label: t("fields.phone"), value: customer.phone },
{ label: t("fields.mobile"), value: customer.mobile },
{ label: t("fields.email"), value: customer.email },
{ label: t("fields.notes"), value: customer.notes },
{ label: t("fields.billingNotes"), value: customer.billingNotes },
{ label: t("fields.createdAt"), value: format.dateTime(customer.createdAt, { dateStyle: "medium", timeStyle: "short" }) },
{ label: t("fields.updatedAt"), value: format.dateTime(customer.updatedAt, { dateStyle: "medium", timeStyle: "short" }) },
]}
/>
</Card>
)}
{tab === "contacts" && (
<div className="space-y-3">
{canWrite && (
<Link href={withParam("contact", "new")} className={primaryButtonClass}>
<Plus className="size-4" aria-hidden /> {t("contacts.new")}
</Link>
)}
{customer.contacts.length === 0 && <p className="text-[13px] text-muted-foreground">{t("contacts.empty")}</p>}
<ul className="grid gap-3 md:grid-cols-2">
{customer.contacts.map((c) => (
<li key={c.id} className="shadow-card rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold">{c.name}</p>
{c.role && <p className="text-[12.5px] text-muted-foreground">{c.role}</p>}
</div>
{c.preferredChannel && <Pill tone="info">{t("contacts.preferred")}: {t(`contacts.channel.${c.preferredChannel}`)}</Pill>}
</div>
<ul className="mt-2 space-y-1 text-[13px]">
{c.phone && (
<li className="flex items-center gap-2">
<Phone className="size-3.5 text-muted-foreground" aria-hidden />
<a href={`tel:${c.phone}`} className="inline-flex min-h-8 items-center hover:underline">{c.phone}</a>
</li>
)}
{c.mobile && (
<li className="flex items-center gap-2">
<Smartphone className="size-3.5 text-muted-foreground" aria-hidden />
<a href={`tel:${c.mobile}`} className="inline-flex min-h-8 items-center hover:underline">{c.mobile}</a>
</li>
)}
{c.email && (
<li className="flex items-center gap-2">
<Mail className="size-3.5 text-muted-foreground" aria-hidden />
<a href={`mailto:${c.email}`} className="inline-flex min-h-8 items-center break-all hover:underline">{c.email}</a>
</li>
)}
</ul>
{c.notes && <p className="mt-2 text-[12.5px] whitespace-pre-line text-muted-foreground">{c.notes}</p>}
{canWrite && (
<div className="mt-3 flex flex-wrap gap-2">
<Link href={withParam("contact", c.id)} className={buttonLinkClass}>
{tc("edit")}
</Link>
<ActionButtonForm
action={deleteContactAction.bind(null, c.id, id)}
label={t("contacts.delete")}
confirmText={t("contacts.deleteConfirm")}
namespace="customers"
tone="danger"
/>
</div>
)}
</li>
))}
</ul>
</div>
)}
{tab === "sites" && <SitesTab customerId={id} ctx={ctx} canCreate={can(ctx, "site:write") && !isMerged} />}
{tab === "orders" && (
<OrdersTab customerId={id} ctx={ctx} />
)}
{tab === "documents" && (
<DocumentPanel
ctx={ctx}
rows={(await listDocuments(ctx, { customerId: id, pageSize: 500 })).items}
baseHref={here}
links={{ customerId: id }}
searchParams={sp}
defaultCategory="order_confirmation"
/>
)}
{canWrite && sp.edit && (
<Modal title={t("form.editTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
<CustomerForm mode="edit" action={updateCustomerAction.bind(null, id)} initial={toFormValues(customer)} closeHref={here} />
</Modal>
)}
{canWrite && sp.contact && (sp.contact === "new" || customer.contacts.some((c) => c.id === sp.contact)) && (
<Modal title={sp.contact === "new" ? t("contacts.createTitle") : t("contacts.editTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
<ContactForm
action={sp.contact === "new" ? createContactAction.bind(null, id) : updateContactAction.bind(null, sp.contact, id)}
initial={sp.contact === "new" ? {} : toFormValues(customer.contacts.find((c) => c.id === sp.contact)!)}
closeHref={here}
/>
</Modal>
)}
{canMerge && sp.merge && (
<Modal title={t("merge.title")} sub={t("merge.sub")} closeHref={here} closeLabel={tc("close")}>
<MergeForm
action={mergeCustomerAction.bind(null, id)}
source={{ displayName: name, customerNumber: customer.customerNumber }}
candidates={await findDuplicateCustomers(ctx, customer, { excludeId: id })}
closeHref={here}
/>
</Modal>
)}
{canWrite && sp.delete && (
<Modal title={t("detail.deleteTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
<div className="space-y-4 p-5">
<p className="text-[13px]">{t("detail.deleteHint")}</p>
<div className="flex flex-wrap gap-2">
<ActionButtonForm action={deleteCustomerAction.bind(null, id)} label={t("detail.deleteConfirm")} tone="danger" namespace="customers" />
<Link href={here} className={buttonLinkClass}>
{t("form.cancel")}
</Link>
</div>
</div>
</Modal>
)}
<span hidden>{ts("title")}</span>
</main>
);
}
async function SitesTab({ customerId, ctx, canCreate }: { customerId: string; ctx: Awaited<ReturnType<typeof requirePageContext>>; canCreate: boolean }) {
const [t, ts] = await Promise.all([getTranslations("customers"), getTranslations("sites")]);
if (!can(ctx, "site:read")) return <p className="text-[13px] text-muted-foreground">{t("errors.forbidden")}</p>;
const sites = await listSites(ctx, { customerId, status: "all", pageSize: 100 });
return (
<div className="space-y-3">
{canCreate && (
<Link href={`/sites?new=1&customerId=${customerId}`} className={primaryButtonClass}>
<Plus className="size-4" aria-hidden /> {t("sites.new")}
</Link>
)}
{sites.items.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t("sites.empty")}</p>
) : (
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{ts("columns.name")}</TableHead>
<TableHead className="hidden sm:table-cell">{ts("columns.address")}</TableHead>
<TableHead>{ts("columns.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sites.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 text-muted-foreground sm:table-cell">{formatAddress(s) || "—"}</TableCell>
<TableCell>
<SiteStatusPill status={s.status} label={ts(`status.${s.status}`)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
async function OrdersTab({ customerId, ctx }: { customerId: string; ctx: Awaited<ReturnType<typeof requirePageContext>> }) {
const [t, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("sites"), getFormatter()]);
const orders = await listCustomerWorkOrders(ctx, customerId);
return (
<div className="space-y-2">
<p className="text-[12.5px] text-muted-foreground">{t("orders.hint")}</p>
{orders.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t("orders.empty")}</p>
) : (
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("orders.columns.number")}</TableHead>
<TableHead>{t("orders.columns.title")}</TableHead>
<TableHead className="hidden md:table-cell">{t("orders.columns.site")}</TableHead>
<TableHead className="hidden lg:table-cell">{t("orders.columns.team")}</TableHead>
<TableHead className="hidden sm:table-cell">{t("orders.columns.date")}</TableHead>
<TableHead>{t("orders.columns.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((o) => (
<TableRow key={o.id}>
<TableCell className="p-0">
<Link href={`/work-orders/${o.id}`} className="block px-3 py-3 font-mono text-[12.5px] font-semibold">{o.number}</Link>
</TableCell>
<TableCell className="p-0">
<Link href={`/work-orders/${o.id}`} className="block px-3 py-3">{o.title}</Link>
</TableCell>
<TableCell className="hidden md:table-cell">{o.site?.name ?? "—"}</TableCell>
<TableCell className="hidden lg:table-cell">{o.team?.name ?? "—"}</TableCell>
<TableCell className="hidden sm:table-cell">{o.plannedStart ? format.dateTime(o.plannedStart, { dateStyle: "medium" }) : "—"}</TableCell>
<TableCell>
<OrderStatusPill status={o.status} label={ts(`statusGroup.${orderStatusGroup(o.status)}`)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
+146 -3
View File
@@ -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 w-full min-w-0 flex-col gap-1 text-[12.5px] font-semibold sm:w-auto sm:min-w-[14rem] sm:flex-1">
{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>
);
}
+120 -3
View File
@@ -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>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { DocumentCategory, DocumentVisibility } from "@prisma/client";
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context";
import { storeFile } from "@/server/services/documents/store";
/**
* Multipart upload for the backoffice document tabs (customer, site, /documents).
* A route handler instead of a server action because server action bodies are limited to 1 MB.
* Note: with the proxy active, Next.js buffers at most `proxyClientMaxBodySize` (default 10 MB);
* larger bodies fail to parse and are answered with `too_large`.
*
* Browser forms get a 303 redirect back to `returnTo` with `?docOk=1` or `?docError=<reason>`;
* clients sending `Accept: application/json` get JSON (`{ data: { id } }` or the error format).
*/
export async function POST(req: Request) {
const wantsJson = (req.headers.get("accept") ?? "").includes("application/json");
let returnTo = "/documents";
try {
assertSameOrigin(req);
const ctx = await requireApiContext("documents");
let form: FormData;
try {
form = await req.formData();
} catch {
throw new ServiceError("invalid", "unreadable upload", { reason: "too_large" });
}
returnTo = safeReturnTo(form.get("returnTo"));
const file = form.get("file");
if (!(file instanceof File)) throw new ServiceError("invalid", "file missing", { reason: "empty_file" });
const str = (k: string) => {
const v = form.get(k);
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
};
const category = str("category");
const visibility = str("visibility");
if (!category || !(category in DocumentCategory)) throw new ServiceError("invalid", "category", { reason: "invalid_category" });
if (!visibility || !(visibility in DocumentVisibility)) throw new ServiceError("invalid", "visibility", { reason: "visibility_not_allowed" });
const document = await storeFile(ctx, {
bytes: new Uint8Array(await file.arrayBuffer()),
fileName: file.name,
declaredMime: file.type || "application/octet-stream",
category: category as DocumentCategory,
visibility: visibility as DocumentVisibility,
title: str("title"),
lineageId: str("lineageId"),
links: { customerId: str("customerId"), siteId: str("siteId"), workOrderId: str("workOrderId") },
});
if (wantsJson) return Response.json({ data: { id: document.id, version: document.version, lineageId: document.lineageId } }, { status: 201 });
return redirectTo(returnTo, { docOk: "1" });
} catch (err) {
if (wantsJson) return toErrorResponse(err);
if (err instanceof ApiError && err.code === "unauthorized") return redirectTo("/login", {});
const reason =
err instanceof ServiceError
? String((err.details as { reason?: string } | undefined)?.reason ?? err.code)
: err instanceof ApiError
? err.code
: "generic";
if (!(err instanceof ServiceError) && !(err instanceof ApiError)) console.error("[documents/upload] failed", err);
return redirectTo(returnTo, { docError: reason });
}
}
/** Only same-app relative paths; everything else falls back to /documents (no open redirect). */
function safeReturnTo(value: FormDataEntryValue | null): string {
if (typeof value !== "string") return "/documents";
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/documents";
return value.slice(0, 500);
}
function redirectTo(path: string, params: Record<string, string>): Response {
const [pathname, query = ""] = path.split("?");
const sp = new URLSearchParams(query);
sp.delete("docOk");
sp.delete("docError");
for (const [k, v] of Object.entries(params)) sp.set(k, v);
const qs = sp.toString();
return new Response(null, { status: 303, headers: { Location: `${pathname}${qs ? `?${qs}` : ""}` } });
}
-61
View File
@@ -1,61 +0,0 @@
import { requireSession } from "@/server/auth";
import { storage } from "@/server/storage/adapter";
/**
* Download-Route für Dateien im Objektspeicher (Garage/S3) anhand ihres Storage-Keys.
*
* Mandanten-Isolation:
* Der Key ist mandantenpräfixiert (`<tenantId>/…`). Er MUSS mit dem Tenant der
* aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404 abgewiesen
* (keine Existenz-Preisgabe).
*
* TODO(documents): Defense in Depth wiederherstellen — sobald das Craftvia-Document-Modell
* existiert, zusätzlich prüfen, dass der Key in einer mandantengebundenen Referenz
* (Document.storageKey) vorkommt und der Nutzer das Dokument sehen darf
* (document:read bzw. document:read_internal für interne Dokumente).
*
* Auslieferung mit `Content-Disposition: attachment` und `X-Content-Type-Options:
* nosniff` (F-07) — kein Inline-Rendering, kein MIME-Sniffing.
*
* Route-Handler laufen NICHT durch das Layout-Gate; die Auth wird hier eigenständig
* über `requireSession` erzwungen.
*/
export async function GET(
_req: Request,
{ params }: { params: Promise<{ key: string[] }> },
) {
const session = await requireSession();
const tenantId = session.user.tenantId;
const { key: segments } = await params;
// Catch-all-Segmente sind bereits URL-dekodiert; zum Objekt-Key zusammenfügen.
const key = (segments ?? []).join("/");
// Pfad-Traversal ausschließen und Mandantenpräfix erzwingen.
if (
!tenantId ||
!key ||
key.includes("..") ||
key.includes("\0") ||
!key.startsWith(`${tenantId}/`)
) {
return new Response("Nicht gefunden.", { status: 404 });
}
const content = await storage.get(key);
if (!content) {
// Kein Byte-Backend (Stub) oder Objekt fehlt → 404.
return new Response("Datei nicht verfügbar.", { status: 404 });
}
const filename = content.filename.replace(/["\\]/g, "_");
const headers = new Headers({
"Content-Type": content.contentType ?? "application/octet-stream",
"Content-Disposition": `attachment; filename="${filename}"`,
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, no-store",
});
if (content.size != null) headers.set("Content-Length", String(content.size));
return new Response(content.stream, { headers });
}
+49
View File
@@ -0,0 +1,49 @@
import { requireApiContext } from "@/server/api/context";
import { ApiError } from "@/server/api/respond";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { openDocumentContent } from "@/server/services/documents/access";
/**
* Document download by id (ARCHITEKTUR §4.3). Replaces the former storage-key route.
*
* Authorization on EVERY request (no public or long-lived links):
* session + DB-authoritative `document:read` → document in the tenant (dbForTenant) →
* visibility allowed for the user → work order in scope (`requireVisibleWorkOrder` semantics via
* `workOrderScope`) or site/customer in `siteScope`/`customerScope`. Everything else → 404
* without revealing existence.
*
* Delivered as attachment with nosniff (F-07); images still render in <img> tags.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ documentId: string }> }) {
let ctx: ServiceCtx;
try {
ctx = await requireApiContext(null, "document:read");
} catch (err) {
const status = err instanceof ApiError && err.code === "unauthorized" ? 401 : 403;
return new Response(status === 401 ? "Nicht angemeldet." : "Kein Zugriff.", { status, headers: { "Cache-Control": "no-store" } });
}
const { documentId } = await params;
if (!documentId || documentId.length > 64) return notFound();
try {
const { document, content } = await openDocumentContent(ctx, documentId);
const asciiName = document.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const headers = new Headers({
"Content-Type": document.mimeType || content.contentType || "application/octet-stream",
"Content-Disposition": `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(document.fileName)}`,
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, no-store",
});
if (content.size != null) headers.set("Content-Length", String(content.size));
return new Response(content.stream, { headers });
} catch (err) {
if (err instanceof ServiceError || (err instanceof Error && /Tenant isolation violation/.test(err.message))) return notFound();
console.error("[files] download failed", err);
return new Response("Datei nicht verfügbar.", { status: 500, headers: { "Cache-Control": "no-store" } });
}
}
function notFound() {
return new Response("Nicht gefunden.", { status: 404, headers: { "Cache-Control": "no-store" } });
}
+231
View File
@@ -0,0 +1,231 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { AlertTriangle, ArrowLeft, Car, KeyRound, MapPin, Wrench } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { PageHead } from "@/components/mockup-ui";
import { Modal } from "@/components/modal";
import { ActionButtonForm } from "@/components/customers/action-form";
import { SiteStatusPill } from "@/components/customers/status";
import { Banner, buttonLinkClass, Card, DefinitionList, TabNav } from "@/components/customers/form-ui";
import { DocumentPanel } from "@/components/documents/document-panel";
import { SiteForm } from "@/components/sites/site-form";
import { SiteHistory } from "@/components/sites/site-history";
import { deleteSiteAction, updateSiteAction } 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 { listDocuments } from "@/server/services/documents/access";
import { getSiteHistory } from "@/server/services/sites/history";
import { siteMapUrl } from "@/server/services/sites/map-link";
import { getSite } from "@/server/services/sites/sites";
const TABS = ["master", "documents", "history"] as const;
type Tab = (typeof TABS)[number];
type SearchParams = Promise<{
tab?: string;
edit?: string;
delete?: string;
approved?: string;
docOk?: string;
docError?: string;
docEdit?: string;
docVersion?: string;
}>;
export default async function SiteDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: SearchParams }) {
const ctx = await requirePageContext("sites");
const [{ id }, sp] = await Promise.all([params, searchParams]);
const [t, tc] = await Promise.all([getTranslations("sites"), getTranslations("common")]);
let site;
try {
site = await getSite(ctx, id);
} catch (err) {
if (err instanceof ServiceError) notFound();
throw err;
}
const tab: Tab = (TABS as readonly string[]).includes(sp.tab ?? "") ? (sp.tab as Tab) : "master";
const base = `/sites/${id}`;
const tabHref = (k: Tab) => (k === "master" ? base : `${base}?tab=${k}`);
const here = tabHref(tab);
const canWrite = can(ctx, "site:write") && can(ctx, "customer:read");
const mapUrl = siteMapUrl(site);
const fullHistoryAccess = can(ctx, "work_order:read_all");
const notes = [
{ key: "accessNotes", icon: KeyRound, value: site.accessNotes, tone: "info" as const },
{ key: "parkingNotes", icon: Car, value: site.parkingNotes, tone: "info" as const },
{ key: "safetyNotes", icon: AlertTriangle, value: site.safetyNotes, tone: "warn" as const },
{ key: "technicalNotes", icon: Wrench, value: site.technicalNotes, tone: "info" as const },
].filter((n) => n.value?.trim());
return (
<main className="flex-1 p-4 sm:p-6">
<Link href="/sites" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
</Link>
<PageHead
crumb={`${t("title")} · ${customerDisplayName(site.customer)}`}
title={site.name}
sub={formatAddress(site, { withCountry: true }) || undefined}
actions={
<div className="flex flex-wrap items-center gap-2">
<SiteStatusPill status={site.status} label={t(`status.${site.status}`)} />
{mapUrl ? (
<a href={mapUrl} target="_blank" rel="noopener noreferrer" className={buttonLinkClass}>
<MapPin className="size-4" aria-hidden /> {t("detail.map")}
</a>
) : (
<span className="text-[12px] text-muted-foreground">{t("detail.noMap")}</span>
)}
{canWrite && (
<Link href={`${here}${here.includes("?") ? "&" : "?"}edit=1`} className={buttonLinkClass}>
{t("detail.edit")}
</Link>
)}
{canWrite && (
<Link href={`${here}${here.includes("?") ? "&" : "?"}delete=1`} className={buttonLinkClass}>
{t("detail.delete")}
</Link>
)}
</div>
}
/>
<TabNav label={t("title")} active={tab} tabs={TABS.map((k) => ({ key: k, label: t(`tabs.${k}`), href: tabHref(k) }))} />
{tab === "master" && (
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
<Card>
<DefinitionList
items={[
{
label: t("detail.customer"),
value: can(ctx, "customer:read") ? (
<Link href={`/customers/${site.customer.id}`} className="font-semibold hover:underline">
{customerDisplayName(site.customer)} · {site.customer.customerNumber}
</Link>
) : (
customerDisplayName(site.customer)
),
},
{ label: t("sections.address"), value: formatAddress(site, { withCountry: true }) },
{ label: t("fields.contactId"), value: site.contact ? [site.contact.name, site.contact.phone ?? site.contact.mobile, site.contact.email].filter(Boolean).join(" · ") : null },
{ label: t("fields.onSiteContact"), value: site.onSiteContact },
{ label: t("fields.phone"), value: site.phone },
{
label: `${t("fields.latitude")} / ${t("fields.longitude")}`,
value: site.latitude != null && site.longitude != null ? `${site.latitude}, ${site.longitude}` : null,
},
]}
/>
</Card>
<section aria-label={t("sections.notes")} className="space-y-3">
{notes.length === 0 && <p className="text-[13px] text-muted-foreground">{t("detail.noNotes")}</p>}
{notes.map((n) => (
<div
key={n.key}
className={
n.tone === "warn"
? "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"
}
>
<p className={`flex items-center gap-2 text-[12.5px] font-semibold ${n.tone === "warn" ? "text-[var(--warn)]" : "text-muted-foreground"}`}>
<n.icon className="size-4" aria-hidden /> {t(`fields.${n.key}`)}
</p>
<p className="mt-1 text-sm whitespace-pre-line">{n.value}</p>
</div>
))}
</section>
</div>
)}
{tab === "documents" && (
<DocumentPanel
ctx={ctx}
rows={(await listDocuments(ctx, { siteId: id, pageSize: 500 })).items}
baseHref={here}
links={{ siteId: id, customerId: site.customer.id }}
searchParams={sp}
defaultCategory="technical_drawing"
/>
)}
{tab === "history" && (
<HistoryTab siteId={id} ctx={ctx} onlyApproved={!fullHistoryAccess || sp.approved === "1"} canToggle={fullHistoryAccess} />
)}
{canWrite && sp.edit && (
<Modal title={t("form.editTitle")} sub={site.name} closeHref={here} closeLabel={tc("close")}>
<SiteForm
mode="edit"
action={updateSiteAction.bind(null, id)}
customers={await editCustomerOptions(ctx, site.customer)}
contacts={await siteContacts(ctx, site.customer.id)}
initial={Object.fromEntries(Object.entries(site).map(([k, v]) => [k, v === null || typeof v === "object" ? "" : String(v)]))}
closeHref={here}
/>
</Modal>
)}
{canWrite && sp.delete && (
<Modal title={t("detail.deleteTitle")} sub={site.name} closeHref={here} closeLabel={tc("close")}>
<div className="space-y-4 p-5">
<p className="text-[13px]">{t("detail.deleteHint")}</p>
<div className="flex flex-wrap gap-2">
<ActionButtonForm action={deleteSiteAction.bind(null, id)} label={t("detail.deleteConfirm")} tone="danger" namespace="sites" />
<Link href={here} className={buttonLinkClass}>
{t("form.cancel")}
</Link>
</div>
</div>
</Modal>
)}
</main>
);
}
async function editCustomerOptions(ctx: Awaited<ReturnType<typeof requirePageContext>>, current: { id: string; customerNumber: string | null; companyName: string | null; firstName: string | null; lastName: string | null }) {
const options = (await customerOptions(ctx)).map((c) => ({ id: c.id, label: `${customerDisplayName(c)} · ${c.customerNumber ?? ""}` }));
if (!options.some((o) => o.id === current.id)) options.unshift({ id: current.id, label: `${customerDisplayName(current)} · ${current.customerNumber ?? ""}` });
return options;
}
async function siteContacts(ctx: Awaited<ReturnType<typeof requirePageContext>>, customerId: string) {
try {
const customer = await getCustomer(ctx, customerId);
return customer.contacts.map((c) => ({ id: c.id, label: c.role ? `${c.name} (${c.role})` : c.name }));
} catch (err) {
if (err instanceof ServiceError) return [];
throw err;
}
}
async function HistoryTab({ siteId, ctx, onlyApproved, canToggle }: { siteId: string; ctx: Awaited<ReturnType<typeof requirePageContext>>; onlyApproved: boolean; canToggle: boolean }) {
const t = await getTranslations("sites");
const history = await getSiteHistory(ctx, siteId, { onlyApproved, pageSize: 100 });
const base = `/sites/${siteId}?tab=history`;
return (
<section aria-label={t("history.title")} className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="font-heading text-[15px] font-semibold">{t("history.title")}</h2>
{canToggle && (
<div className="flex gap-2">
<Link href={base} aria-current={!onlyApproved ? "page" : undefined} className={`${buttonLinkClass} ${!onlyApproved ? "border-[var(--ui-accent)]" : ""}`}>
{t("history.showAll")}
</Link>
<Link href={`${base}&approved=1`} aria-current={onlyApproved ? "page" : undefined} className={`${buttonLinkClass} ${onlyApproved ? "border-[var(--ui-accent)]" : ""}`}>
{t("history.onlyApproved")}
</Link>
</div>
)}
</div>
{history.onlyApproved && <Banner tone="info">{t("history.approvedOnlyHint")}</Banner>}
<SiteHistory entries={history.items} />
</section>
);
}
+150 -3
View File
@@ -1,5 +1,152 @@
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 { 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";
export default function Page() {
return <ModulePlaceholder moduleKey="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 w-full min-w-0 flex-col gap-1 text-[12.5px] font-semibold sm:w-auto sm:min-w-[14rem] sm:flex-1">
{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>
);
}
+166 -3
View File
@@ -1,5 +1,168 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Plus } from "lucide-react";
import { getFormatter, 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 { ActionButtonForm } from "@/components/customers/action-form";
import { TeamStatusPill } from "@/components/customers/status";
import { buttonLinkClass, primaryButtonClass } from "@/components/customers/form-ui";
import { TeamForm, type TeamFormValues } from "@/components/teams/team-form";
import { deleteTeamAction, saveTeamAction } from "@/server/actions/teams/teams";
import { requirePageContext } from "@/server/api/context";
import { can } from "@/server/services/context";
import { listTeams, teamUserOptions } from "@/server/services/teams/teams";
export default function Page() {
return <ModulePlaceholder moduleKey="teams" />;
type SearchParams = Promise<{ new?: string; edit?: string; inactive?: string }>;
const day = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : "");
/** Teams (spec §11.1): list with team lead, members (validity), phone, vehicle, area; edit popup. */
export default async function TeamsPage({ searchParams }: { searchParams: SearchParams }) {
const ctx = await requirePageContext("teams");
if (!can(ctx, "team:read")) notFound();
const [t, tc, format] = await Promise.all([getTranslations("teams"), getTranslations("common"), getFormatter()]);
const sp = await searchParams;
const includeInactive = sp.inactive === "1";
const canManage = can(ctx, "team:manage");
const [teams, users] = await Promise.all([listTeams(ctx, { includeInactive }), canManage ? teamUserOptions(ctx) : Promise.resolve([])]);
const listHref = includeInactive ? "/teams?inactive=1" : "/teams";
const withParam = (k: string, v: string) => `${listHref}${listHref.includes("?") ? "&" : "?"}${k}=${encodeURIComponent(v)}`;
const now = new Date();
const isCurrent = (m: { validFrom: Date; validTo: Date | null }) => m.validFrom <= now && (!m.validTo || m.validTo > now);
const editTeam = sp.edit ? teams.find((x) => x.id === sp.edit) : undefined;
const initialFor = (team?: (typeof teams)[number]): TeamFormValues =>
team
? {
name: team.name,
leaderUserId: team.leaderUserId,
status: team.status,
phone: team.phone,
vehicle: team.vehicle,
area: team.area,
notes: team.notes,
members: team.members.map((m) => ({ userId: m.userId, validFrom: day(m.validFrom), validTo: day(m.validTo) })),
}
: { status: "active", members: [] };
return (
<main className="flex-1 p-4 sm:p-6">
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("sub")}
actions={
<div className="flex flex-wrap gap-2">
<Link href={includeInactive ? "/teams" : "/teams?inactive=1"} className={buttonLinkClass}>
{includeInactive ? t("hideInactive") : t("showInactive")}
</Link>
{canManage && (
<Link href={withParam("new", "1")} className={primaryButtonClass}>
<Plus className="size-4" aria-hidden /> {t("new")}
</Link>
)}
</div>
}
/>
{!canManage && <p className="mb-3 text-[12.5px] text-muted-foreground">{t("readOnly")}</p>}
<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.leader")}</TableHead>
<TableHead>{t("columns.members")}</TableHead>
<TableHead className="hidden md:table-cell">{t("columns.phone")}</TableHead>
<TableHead className="hidden lg:table-cell">{t("columns.vehicle")}</TableHead>
<TableHead className="hidden lg:table-cell">{t("columns.area")}</TableHead>
<TableHead>{t("columns.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teams.map((team) => {
const current = team.members.filter(isCurrent);
return (
<TableRow key={team.id}>
<TableCell className="p-0">
{canManage ? (
<Link href={withParam("edit", team.id)} className="block px-3 py-3 font-semibold">
{team.name}
</Link>
) : (
<span className="block px-3 py-3 font-semibold">{team.name}</span>
)}
</TableCell>
<TableCell className="hidden sm:table-cell">{team.leader?.name ?? "—"}</TableCell>
<TableCell className="whitespace-normal">
<span className="text-[12.5px] font-semibold">{t("members.count", { count: current.length })}</span>
{team.members.length > 0 && (
<ul className="mt-0.5 text-[12px] text-muted-foreground">
{team.members.map((m) => (
<li key={m.id}>
{m.user.name} ·{" "}
{isCurrent(m)
? t("members.current")
: m.validFrom > now
? t("members.upcoming", { date: format.dateTime(m.validFrom, { dateStyle: "medium" }) })
: t("members.ended")}
</li>
))}
</ul>
)}
</TableCell>
<TableCell className="hidden md:table-cell">{team.phone ?? "—"}</TableCell>
<TableCell className="hidden lg:table-cell">{team.vehicle ?? "—"}</TableCell>
<TableCell className="hidden lg:table-cell">{team.area ?? "—"}</TableCell>
<TableCell>
<TeamStatusPill status={team.status} label={t(`status.${team.status}`)} />
</TableCell>
</TableRow>
);
})}
{teams.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-6 text-center text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{canManage && sp.new && (
<Modal title={t("form.createTitle")} sub={t("form.sub")} closeHref={listHref} closeLabel={tc("close")}>
<TeamForm mode="create" action={saveTeamAction.bind(null, null)} initial={initialFor()} users={users} closeHref={listHref} />
</Modal>
)}
{canManage && editTeam && (
<Modal
title={t("form.editTitle")}
sub={editTeam.name}
closeHref={listHref}
closeLabel={tc("close")}
footer={
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<p className="text-[12px] text-muted-foreground">{t("form.deleteHint")}</p>
<ActionButtonForm
action={deleteTeamAction.bind(null, editTeam.id)}
label={t("form.delete")}
confirmText={t("form.deleteConfirm")}
namespace="teams"
tone="danger"
successHref={listHref}
/>
</div>
}
>
<TeamForm mode="edit" action={saveTeamAction.bind(null, editTeam.id)} initial={initialFor(editTeam)} users={users} closeHref={listHref} />
</Modal>
)}
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { json, readJson, withApi } from "@/server/api/respond";
import { getCustomer, updateCustomer } from "@/server/services/customers/customers";
import type { CustomerPatchInput } from "@/server/services/customers/schemas";
type Ctx = { params: Promise<{ id: string }> };
/** GET /api/v1/customers/:id — customer incl. contacts (scope applies, otherwise 404). */
export const GET = withApi(async (_req: Request, { params }: Ctx) => {
const ctx = await requireApiContext("customers", "customer:read");
const { id } = await params;
return json({ data: await getCustomer(ctx, id) });
});
/** PATCH /api/v1/customers/:id — absent fields unchanged, null clears. */
export const PATCH = withApi(async (req: Request, { params }: Ctx) => {
assertSameOrigin(req);
const ctx = await requireApiContext("customers", "customer:write");
const { id } = await params;
const body = (await readJson(req)) as CustomerPatchInput | null;
return json({ data: await updateCustomer(ctx, id, body ?? {}) });
});
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond";
import { createCustomer, CUSTOMER_LIST_STATUSES, listCustomers } from "@/server/services/customers/customers";
import type { CustomerCreateInput } from "@/server/services/customers/schemas";
const statusParam = z.enum([...CUSTOMER_LIST_STATUSES, "all"]).optional();
/** GET /api/v1/customers?q&status&page&pageSize */
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("customers", "customer:read");
const url = new URL(req.url);
const { page, pageSize } = parsePagination(url);
const status = statusParam.parse(url.searchParams.get("status") ?? undefined);
const result = await listCustomers(ctx, { q: url.searchParams.get("q") ?? undefined, status, page, pageSize });
return paginated(result.items, result.total, result.page, result.pageSize);
});
/**
* POST /api/v1/customers — body: customer fields + optional `acknowledgeDuplicates: true`.
* Possible duplicates without acknowledgement → 409 `{ error: { code: "conflict", details: { reason: "possible_duplicates", candidates } } }`.
*/
export const POST = withApi(async (req: Request) => {
assertSameOrigin(req);
const ctx = await requireApiContext("customers", "customer:write");
const body = (await readJson(req)) as Record<string, unknown> | null;
const customer = await createCustomer(ctx, (body ?? {}) as CustomerCreateInput, {
acknowledgeDuplicates: body?.acknowledgeDuplicates === true,
});
return json({ data: customer }, { status: 201 });
});
@@ -0,0 +1,21 @@
import { requireApiContext } from "@/server/api/context";
import { json, parsePagination, withApi } from "@/server/api/respond";
import { getSiteHistory } from "@/server/services/sites/history";
/**
* GET /api/v1/sites/:id/history?onlyApproved=true&page&pageSize
* Field roles always receive released deployments only (see getSiteHistory).
*/
export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("sites", "site:read");
const { id } = await params;
const url = new URL(req.url);
const { page, pageSize } = parsePagination(url, { pageSize: 50 });
const flag = url.searchParams.get("onlyApproved");
const result = await getSiteHistory(ctx, id, { onlyApproved: flag === "true" || flag === "1", page, pageSize });
return json({
data: result.items,
pagination: { page: result.page, pageSize: result.pageSize, total: result.total },
meta: { onlyApproved: result.onlyApproved },
});
});
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond";
import { createSite, listSites, SITE_STATUSES, type SiteCreateInput } from "@/server/services/sites/sites";
const statusParam = z.enum([...SITE_STATUSES, "all"]).optional();
/** GET /api/v1/sites?q&customerId&status&page&pageSize */
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("sites", "site:read");
const url = new URL(req.url);
const { page, pageSize } = parsePagination(url);
const result = await listSites(ctx, {
q: url.searchParams.get("q") ?? undefined,
customerId: url.searchParams.get("customerId") ?? undefined,
status: statusParam.parse(url.searchParams.get("status") ?? undefined),
page,
pageSize,
});
return paginated(result.items, result.total, result.page, result.pageSize);
});
/** POST /api/v1/sites */
export const POST = withApi(async (req: Request) => {
assertSameOrigin(req);
const ctx = await requireApiContext("sites", "site:write");
const body = (await readJson(req)) as SiteCreateInput | null;
return json({ data: await createSite(ctx, body ?? ({} as SiteCreateInput)) }, { status: 201 });
});