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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user