L2 Aufträge & Backoffice: Backoffice-UI (Liste, Detail, Konflikte, Dashboard, Suche, Einstellungen) + Texte de/en
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,170 @@
|
||||
import { LayoutDashboard } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
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/action-form";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Neutrales Dashboard (Platzhalter). Die Kacheln (offene Aufträge, heutige Einsätze,
|
||||
* freizugebende Berichte, Notdienst) liefern die Fachmodule.
|
||||
*/
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ module?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
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-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5">
|
||||
<LayoutDashboard className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" aria-hidden />
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{t("placeholderTitle")}</p>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{t("placeholder")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!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" : `/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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user