Files
craftvia/src/app/(app)/dashboard/page.tsx
T
msolarczekandClaude Opus 5 7825245901 Fix: /dashboard 500 – buttonCls aus Client-Modul herausgelöst
Server-Komponenten (Dashboard, Aufträge, Suche, Checklisten, Auftragsdetail) riefen
buttonCls() aus einem "use client"-Modul auf; das bricht zur Laufzeit (gemeldet von L8).
Neu: src/components/work-orders/button-cls.ts (server-sicher), action-form re-exportiert.

Nachweis: authentifizierter HTTP-Smoke (Backoffice 13 Seiten, Monteur 6 Seiten) alle 200,
/dashboard vorher 500. tsc/lint grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:27:46 +02:00

171 lines
8.5 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
AlarmClock,
CalendarDays,
CheckCheck,
ClipboardCheck,
ClipboardList,
Hourglass,
PenLine,
Receipt,
RefreshCw,
Siren,
Wrench,
type LucideIcon,
} from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { buttonCls } from "@/components/work-orders/button-cls";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls } from "@/components/work-orders/ui";
import { isoDay, parseListParams, toQuery, type Preset } from "@/lib/work-orders/filters";
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record<string, string | string[] | undefined>;
const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [
{ key: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
{ key: "not_accepted", icon: Hourglass, tone: "var(--warn)" },
{ key: "overdue", icon: AlarmClock, tone: "var(--risk)" },
{ key: "reports_in_review", icon: ClipboardCheck, tone: "var(--info)" },
{ key: "completed", icon: CheckCheck, tone: "var(--ok)" },
{ key: "billing", icon: Receipt, tone: "var(--ok)" },
{ key: "emergency_new", icon: Siren, tone: "var(--risk)" },
{ key: "missing_signatures", icon: PenLine, tone: "var(--warn)" },
{ key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" },
];
/** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */
export default async function DashboardPage({ searchParams }: { searchParams: Promise<SP> }) {
const { session, ctx, can } = await pageContext();
if (!can("work_order:read_all")) redirect("/m");
const sp = await searchParams;
const t = await getTranslations("dashboard");
const tw = await getTranslations("workOrders");
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } });
const moduleEnabled = !moduleRow || moduleRow.enabled;
const f = parseListParams(sp);
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
const [tiles, customers, teams, users, orderTypes] = moduleEnabled
? await Promise.all([getDashboardTiles(ctx, filter), customerFilterOptions(ctx), teamOptions(ctx), userOptions(ctx), listOrderTypes(ctx)])
: [null, [], [], [], []];
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
{sp.module === "disabled" && (
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
{t("moduleDisabled")}
</p>
)}
{!tiles ? (
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
) : (
<>
<details className="shadow-card mb-4 rounded-xl border bg-card" open={Object.values(filter).some(Boolean)}>
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filterTitle")}</summary>
<form method="get" action="/dashboard" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label={tw("filter.from")} htmlFor="d-from">
<input id="d-from" type="date" name="from" defaultValue={f.from ? isoDay(f.from) : ""} className={inputCls} />
</Field>
<Field label={tw("filter.to")} htmlFor="d-to">
<input id="d-to" type="date" name="to" defaultValue={f.to ? isoDay(f.to) : ""} className={inputCls} />
</Field>
<Field label={tw("filter.customer")} htmlFor="d-customer">
<select id="d-customer" name="customerId" defaultValue={f.customerId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{customerDisplayName(c)}
</option>
))}
</select>
</Field>
<Field label={tw("filter.team")} htmlFor="d-team">
<select id="d-team" name="teamId" defaultValue={f.teamId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{teams.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
<Field label={tw("filter.user")} htmlFor="d-user">
<select id="d-user" name="userId" defaultValue={f.userId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<Field label={tw("filter.orderType")} htmlFor="d-type">
<select id="d-type" name="orderTypeId" defaultValue={f.orderTypeId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={tw("filter.priority")} htmlFor="d-prio">
<select id="d-prio" name="priority" defaultValue={f.priority ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{WORK_ORDER_PRIORITIES.map((p) => (
<option key={p} value={p}>
{tw(`priority.${p}`)}
</option>
))}
</select>
</Field>
<div className="flex items-end gap-2">
<button type="submit" className={buttonCls("default")}>
{tw("filter.apply")}
</button>
<Link href="/dashboard" className={buttonCls("ghost")}>
{tw("filter.reset")}
</Link>
</div>
</form>
</details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return (
<li key={key}>
<Link
href={href}
className="shadow-card flex h-full min-h-24 items-start gap-3 rounded-xl border border-l-4 bg-card p-4 transition-colors hover:bg-muted/40 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
style={{ borderLeftColor: count > 0 ? tone : "var(--line)" }}
>
<Icon className="mt-0.5 size-5 shrink-0" style={{ color: tone }} aria-hidden />
<div className="min-w-0">
<p className="text-[13px] font-semibold text-muted-foreground">{t(`tiles.${key}`)}</p>
<p className="font-heading text-3xl leading-tight font-bold text-foreground">{count}</p>
<p className="text-xs text-muted-foreground">{t(`hints.${key}`)}</p>
</div>
</Link>
</li>
);
})}
</ul>
</>
)}
</main>
);
}