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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Search } 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 { Empty, Field, inputCls, Section, StatusBadge } from "@/components/work-orders/ui";
|
||||
import { isoDay, parseListParams } from "@/lib/work-orders/filters";
|
||||
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||
import { customerDisplayName, teamOptions } from "@/server/services/work-orders/options";
|
||||
import { searchAll, type SearchResults } from "@/server/services/work-orders/search";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
|
||||
/** Tenant search (spec §25) — ILIKE across all areas, restricted by the caller's scopes. */
|
||||
export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const { ctx, locale, tz } = await pageContext();
|
||||
const t = await getTranslations("search");
|
||||
const tw = await getTranslations("workOrders");
|
||||
const td = await getTranslations("workOrders.documents");
|
||||
const sp = await searchParams;
|
||||
const q = (one(sp.q) ?? "").trim().slice(0, 100);
|
||||
const f = parseListParams(sp);
|
||||
|
||||
const [results, teams] = await Promise.all([
|
||||
q.length >= 2 ? searchAll(ctx, { q, from: f.from, to: f.to, statuses: f.statuses, teamId: f.teamId }) : Promise.resolve(null),
|
||||
teamOptions(ctx),
|
||||
]);
|
||||
const total = results ? Object.values(results).reduce((n, list) => n + list.length, 0) : 0;
|
||||
const hasFilter = Boolean(f.from || f.to || f.statuses || f.teamId);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={q ? t("resultsFor", { q }) : t("title")} />
|
||||
|
||||
<form method="get" action="/search" role="search" className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input type="search" name="q" defaultValue={q} minLength={2} maxLength={100} aria-label={t("placeholder")} placeholder={t("placeholder")} className={`${inputCls} min-w-0 flex-1`} autoFocus={!q} />
|
||||
<button type="submit" className={buttonCls("primary")}>
|
||||
<Search className="size-4" aria-hidden />
|
||||
{t("submit")}
|
||||
</button>
|
||||
</div>
|
||||
<details className="mt-3" open={hasFilter}>
|
||||
<summary className="flex min-h-11 cursor-pointer items-center text-sm font-semibold">{t("filters.note")}</summary>
|
||||
<div className="grid gap-3 pt-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label={t("filters.from")} htmlFor="s-from">
|
||||
<input id="s-from" type="date" name="from" defaultValue={f.from ? isoDay(f.from) : ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("filters.to")} htmlFor="s-to">
|
||||
<input id="s-to" type="date" name="to" defaultValue={f.to ? isoDay(f.to) : ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("filters.status")} htmlFor="s-status">
|
||||
<select id="s-status" name="status" defaultValue={f.statuses?.[0] ?? ""} className={inputCls}>
|
||||
<option value="">{t("filters.any")}</option>
|
||||
{WORK_ORDER_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{tw(`status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filters.team")} htmlFor="s-team">
|
||||
<select id="s-team" name="teamId" defaultValue={f.teamId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filters.any")}</option>
|
||||
{teams.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<Link href={`/search?q=${encodeURIComponent(q)}`} className="mt-2 inline-flex min-h-11 items-center text-sm font-semibold text-[var(--primary)] hover:underline">
|
||||
{t("filters.reset")}
|
||||
</Link>
|
||||
)}
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{!results ? (
|
||||
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||
) : total === 0 ? (
|
||||
<Empty>{t("noResults", { q })}</Empty>
|
||||
) : (
|
||||
<>
|
||||
<ResultSection title={t("sections.workOrders")} list={results.workOrders}>
|
||||
{(w: SearchResults["workOrders"][number]) => (
|
||||
<Link href={`/work-orders/${w.id}`} className="flex flex-wrap items-center justify-between gap-2 py-2 hover:underline">
|
||||
<span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{w.number}</span> <span className="font-semibold">{w.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{[customerDisplayName(w.customer), w.site?.name, w.site?.city, w.team?.name, formatDate(w.plannedStart, locale, tz)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
<StatusBadge status={w.status} label={tw(`status.${w.status}`)} />
|
||||
</Link>
|
||||
)}
|
||||
</ResultSection>
|
||||
<ResultSection title={t("sections.customers")} list={results.customers}>
|
||||
{(c: SearchResults["customers"][number]) => (
|
||||
<Link href={`/customers/${c.id}`} className="block py-2 hover:underline">
|
||||
<span className="font-semibold">{customerDisplayName(c)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{[c.customerNumber, [c.street, c.houseNumber].filter(Boolean).join(" "), [c.postalCode, c.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</ResultSection>
|
||||
<ResultSection title={t("sections.sites")} list={results.sites}>
|
||||
{(s: SearchResults["sites"][number]) => (
|
||||
<Link href={`/sites/${s.id}`} className="block py-2 hover:underline">
|
||||
<span className="font-semibold">{s.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{[customerDisplayName(s.customer), [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</ResultSection>
|
||||
<ResultSection title={t("sections.contacts")} list={results.contacts}>
|
||||
{(c: SearchResults["contacts"][number]) => (
|
||||
<Link href={`/customers/${c.customer.id}`} className="block py-2 hover:underline">
|
||||
<span className="font-semibold">{c.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{[c.role, customerDisplayName(c.customer), c.phone, c.email].filter(Boolean).join(" · ")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</ResultSection>
|
||||
<ResultSection title={t("sections.documents")} list={results.documents}>
|
||||
{(d: SearchResults["documents"][number]) => (
|
||||
<Link
|
||||
href={d.workOrderId ? `/work-orders/${d.workOrderId}?tab=documents` : d.customerId ? `/customers/${d.customerId}` : `/sites/${d.siteId}`}
|
||||
className="block py-2 hover:underline"
|
||||
>
|
||||
<span className="font-semibold">{d.title || d.fileName}</span>
|
||||
<span className="block text-xs text-muted-foreground">{[td(`categories.${d.category}`), formatDate(d.createdAt, locale, tz)].join(" · ")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</ResultSection>
|
||||
<ResultSection title={t("sections.notes")} list={results.notes}>
|
||||
{(n: SearchResults["notes"][number]) => (
|
||||
<Link href={`/work-orders/${n.workOrder.id}?tab=notes`} className="block py-2 hover:underline">
|
||||
<span className="line-clamp-2 text-sm">{n.text}</span>
|
||||
<span className="block text-xs text-muted-foreground">{[n.workOrder.number, n.workOrder.title, formatDateTime(n.createdAt, locale, tz)].join(" · ")}</span>
|
||||
</Link>
|
||||
)}
|
||||
</ResultSection>
|
||||
<p className="text-xs text-muted-foreground">{t("maxHint")}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultSection<T extends { id: string }>({ title, list, children }: { title: string; list: T[]; children: (item: T) => React.ReactNode }) {
|
||||
if (list.length === 0) return null;
|
||||
return (
|
||||
<Section title={`${title} (${list.length})`}>
|
||||
<ul className="divide-y">
|
||||
{list.map((item) => (
|
||||
<li key={item.id}>{children(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { z } from "zod";
|
||||
import { Plus } from "lucide-react";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
|
||||
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
|
||||
import { Check, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
import { DEFAULT_CHECKLIST_ITEMS, DEFAULT_REQUIRED_PHOTOS } from "@/lib/work-orders/defaults";
|
||||
import { templateItemSchema, templatePhotoSchema, type TemplateItem, type TemplatePhoto } from "@/lib/work-orders/schemas";
|
||||
import { saveChecklistTemplateAction } from "@/server/actions/work_orders/settings";
|
||||
import { listChecklistTemplates, listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
|
||||
const itemsToText = (items: readonly TemplateItem[], suffix: string) =>
|
||||
items.map((i) => `${i.required ? "* " : ""}${i.label}${i.requiresPhoto ? ` ${suffix}` : ""}`).join("\n");
|
||||
const photosToText = (photos: readonly TemplatePhoto[]) => photos.map((p) => p.label).join("\n");
|
||||
|
||||
/** Checklist templates per order type incl. required photos (spec §12.4 / §14.2). */
|
||||
export default async function ChecklistTemplatesPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const { ctx } = await templatesPageContext();
|
||||
const t = await getTranslations("settingsTemplates");
|
||||
const tc = await getTranslations("common");
|
||||
const sp = await searchParams;
|
||||
const [templates, orderTypes] = await Promise.all([listChecklistTemplates(ctx), listOrderTypes(ctx)]);
|
||||
|
||||
const parsed = templates.map((tpl) => ({
|
||||
...tpl,
|
||||
itemList: z.array(templateItemSchema).safeParse(tpl.items).data ?? [],
|
||||
photoList: z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [],
|
||||
}));
|
||||
const editId = one(sp.edit);
|
||||
const editing = editId ? parsed.find((x) => x.id === editId) : undefined;
|
||||
const isNew = one(sp.new) === "1";
|
||||
const suggest = one(sp.suggest) === "1";
|
||||
const base = "/settings/checklists";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("checklists.title")}
|
||||
sub={t("checklists.sub")}
|
||||
actions={
|
||||
<Link href={`${base}?new=1&suggest=1`} scroll={false} className={buttonCls("primary")}>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("checklists.new")}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<SettingsTemplatesNav active="checklists" />
|
||||
|
||||
{parsed.length === 0 ? (
|
||||
<Empty>{t("checklists.empty")}</Empty>
|
||||
) : (
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{parsed.map((tpl) => (
|
||||
<li key={tpl.id}>
|
||||
<Section title={tpl.name} actions={!tpl.active ? <Pill tone="mut">{t("checklists.inactive")}</Pill> : undefined}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tpl.orderType?.name ?? t("checklists.noOrderType")} · {t("checklists.itemCount", { count: tpl.itemList.length })} · {t("checklists.photoCount", { count: tpl.photoList.length })}
|
||||
</p>
|
||||
<Link href={`${base}?edit=${tpl.id}`} scroll={false} className={`${buttonCls("outline")} mt-3`}>
|
||||
{t("checklists.edit")}
|
||||
</Link>
|
||||
</Section>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{(isNew || editing) && (
|
||||
<Modal title={editing ? editing.name : t("checklists.new")} sub={t("checklists.itemsHint")} closeHref={base} closeLabel={tc("close")}>
|
||||
<ActionForm action={saveChecklistTemplateAction} namespace="settingsTemplates" submitLabel={t("checklists.save")} variant="primary" successText={t("saved")} className="grid gap-4 p-5 md:grid-cols-2">
|
||||
{editing && <input type="hidden" name="id" value={editing.id} />}
|
||||
<Field label={`${t("checklists.name")} *`} htmlFor="tpl-name">
|
||||
<input id="tpl-name" name="name" required maxLength={120} defaultValue={editing?.name ?? ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("checklists.orderType")} htmlFor="tpl-type">
|
||||
<select id="tpl-type" name="orderTypeId" defaultValue={editing?.orderTypeId ?? ""} className={inputCls}>
|
||||
<option value="">{t("checklists.noOrderType")}</option>
|
||||
{orderTypes.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("checklists.items")} htmlFor="tpl-items" hint={t("checklists.itemsHint")} className="md:col-span-1">
|
||||
<textarea
|
||||
id="tpl-items"
|
||||
name="items"
|
||||
rows={10}
|
||||
defaultValue={editing ? itemsToText(editing.itemList, t("checklists.photoSuffix")) : suggest ? itemsToText(DEFAULT_CHECKLIST_ITEMS, t("checklists.photoSuffix")) : ""}
|
||||
className={`${inputCls} py-2 font-mono text-xs`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("checklists.photos")} htmlFor="tpl-photos" hint={t("checklists.photosHint")}>
|
||||
<textarea
|
||||
id="tpl-photos"
|
||||
name="requiredPhotos"
|
||||
rows={10}
|
||||
defaultValue={editing ? photosToText(editing.photoList) : suggest ? photosToText(DEFAULT_REQUIRED_PHOTOS) : ""}
|
||||
className={`${inputCls} py-2 font-mono text-xs`}
|
||||
/>
|
||||
</Field>
|
||||
<Check id="tpl-active" name="active" label={t("checklists.active")} defaultChecked={editing ? editing.active : true} />
|
||||
{!editing && suggest && <p className="self-center text-xs text-muted-foreground">{t("checklists.suggestionHint")}</p>}
|
||||
{!editing && !suggest && (
|
||||
<Link href={`${base}?new=1&suggest=1`} scroll={false} className="self-center text-sm font-semibold text-[var(--primary)] hover:underline">
|
||||
{t("checklists.suggestion")}
|
||||
</Link>
|
||||
)}
|
||||
</ActionForm>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ActionForm } from "@/components/work-orders/action-form";
|
||||
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
|
||||
import { Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
import { updateNumberingAction } from "@/server/actions/work_orders/settings";
|
||||
import { listNumberSequences } from "@/server/services/work-orders/settings";
|
||||
|
||||
/** Number sequences: prefix and digits (the counter itself is never reset). */
|
||||
export default async function NumberingPage() {
|
||||
const { ctx } = await templatesPageContext();
|
||||
const t = await getTranslations("settingsTemplates");
|
||||
const sequences = await listNumberSequences(ctx);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("numbering.title")} sub={t("numbering.sub")} />
|
||||
<SettingsTemplatesNav active="numbering" />
|
||||
<Section>
|
||||
<ul className="divide-y">
|
||||
{sequences.map((s) => (
|
||||
<li key={s.key} className="py-3">
|
||||
<ActionForm action={updateNumberingAction} namespace="settingsTemplates" submitLabel={t("numbering.save")} variant="outline" successText={t("saved")} className="grid items-end gap-3 sm:grid-cols-2 md:grid-cols-[1fr_140px_120px_1fr_auto]" footerClassName="mt-0">
|
||||
<input type="hidden" name="key" value={s.key} />
|
||||
<Field label={t("numbering.key")}>
|
||||
<p className="flex min-h-11 items-center font-semibold">{t(`numbering.keys.${s.key}`)}</p>
|
||||
</Field>
|
||||
<Field label={t("numbering.prefix")} htmlFor={`n-prefix-${s.key}`}>
|
||||
<input id={`n-prefix-${s.key}`} name="prefix" maxLength={12} pattern="[A-Za-z0-9\-_/]*" defaultValue={s.prefix} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("numbering.padding")} htmlFor={`n-pad-${s.key}`}>
|
||||
<input id={`n-pad-${s.key}`} name="padding" type="number" min={1} max={10} defaultValue={s.padding} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("numbering.next")}>
|
||||
<p className="flex min-h-11 items-center font-mono text-sm">{`${s.prefix}${String(s.nextValue).padStart(s.padding, "0")}`}</p>
|
||||
</Field>
|
||||
</ActionForm>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ActionForm } from "@/components/work-orders/action-form";
|
||||
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
|
||||
import { Check, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
import { createOrderTypeAction, updateOrderTypeAction } from "@/server/actions/work_orders/settings";
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
|
||||
/** Order types per tenant (spec §10.2). Defaults are created on first access. */
|
||||
export default async function OrderTypesPage() {
|
||||
const { ctx } = await templatesPageContext();
|
||||
const t = await getTranslations("settingsTemplates");
|
||||
const types = await listOrderTypes(ctx);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("orderTypes.title")} sub={t("orderTypes.sub")} />
|
||||
<SettingsTemplatesNav active="order-types" />
|
||||
|
||||
<Section>
|
||||
<ul className="divide-y">
|
||||
{types.map((ot) => (
|
||||
<li key={ot.id} className="py-3">
|
||||
<ActionForm action={updateOrderTypeAction} namespace="settingsTemplates" submitLabel={t("orderTypes.save")} variant="outline" successText={t("saved")} className="grid items-end gap-3 md:grid-cols-[1fr_2fr_auto_auto_120px_auto]" footerClassName="mt-0">
|
||||
<input type="hidden" name="id" value={ot.id} />
|
||||
<Field label={t("orderTypes.key")}>
|
||||
<p className="flex min-h-11 items-center font-mono text-xs text-muted-foreground">{ot.key}</p>
|
||||
</Field>
|
||||
<Field label={t("orderTypes.name")} htmlFor={`ot-name-${ot.id}`}>
|
||||
<input id={`ot-name-${ot.id}`} name="name" required maxLength={80} defaultValue={ot.name} className={inputCls} />
|
||||
</Field>
|
||||
<Check id={`ot-sig-${ot.id}`} name="signatureRequired" label={t("orderTypes.signatureRequired")} defaultChecked={ot.signatureRequired} />
|
||||
<Check id={`ot-act-${ot.id}`} name="active" label={t("orderTypes.active")} defaultChecked={ot.active} />
|
||||
<Field label={t("orderTypes.sortOrder")} htmlFor={`ot-sort-${ot.id}`}>
|
||||
<input id={`ot-sort-${ot.id}`} name="sortOrder" type="number" min={0} max={10000} defaultValue={ot.sortOrder} className={inputCls} />
|
||||
</Field>
|
||||
</ActionForm>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title={t("orderTypes.create")} className="mt-4">
|
||||
<ActionForm action={createOrderTypeAction} namespace="settingsTemplates" submitLabel={t("orderTypes.create")} variant="primary" successText={t("saved")} className="grid items-end gap-3 md:grid-cols-[1fr_2fr_auto_120px]">
|
||||
<Field label={t("orderTypes.key")} htmlFor="ot-new-key" hint={t("orderTypes.keyHint")}>
|
||||
<input id="ot-new-key" name="key" pattern="[a-z0-9_]{2,40}" maxLength={40} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={`${t("orderTypes.name")} *`} htmlFor="ot-new-name">
|
||||
<input id="ot-new-name" name="name" required maxLength={80} className={inputCls} />
|
||||
</Field>
|
||||
<Check id="ot-new-sig" name="signatureRequired" label={t("orderTypes.signatureRequired")} defaultChecked />
|
||||
<Field label={t("orderTypes.sortOrder")} htmlFor="ot-new-sort">
|
||||
<input id="ot-new-sort" name="sortOrder" type="number" min={0} max={10000} defaultValue={100} className={inputCls} />
|
||||
</Field>
|
||||
</ActionForm>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Pencil, Siren, UsersRound } from "lucide-react";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
|
||||
import {
|
||||
ChecklistTab,
|
||||
DocumentsTab,
|
||||
HistoryTab,
|
||||
MaterialTab,
|
||||
NotesTab,
|
||||
OverviewTab,
|
||||
PhotosTab,
|
||||
ReportsTab,
|
||||
TimesTab,
|
||||
} from "@/components/work-orders/detail-tabs";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { Field, inputCls, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
|
||||
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
|
||||
import { WORK_ORDER_STATUSES, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { assignAction, transitionAction, updateWorkOrderAction } from "@/server/actions/work_orders/work-orders";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_shared";
|
||||
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
|
||||
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
import { reasonRequired } from "@/server/services/work-orders/transition";
|
||||
|
||||
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
const idx = (s: WorkOrderStatus) => WORK_ORDER_STATUSES.indexOf(s);
|
||||
|
||||
/** Backoffice work order detail: header with status + next primary action, tabs, popups (assign/edit/reason). */
|
||||
export default async function WorkOrderDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<SP> }) {
|
||||
const { id } = await params;
|
||||
const sp = await searchParams;
|
||||
const { ctx, locale, tz, can } = await pageContext();
|
||||
const t = await getTranslations("workOrders");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
let wo;
|
||||
try {
|
||||
wo = await getWorkOrderDetail(ctx, id);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const tabParam = one(sp.tab);
|
||||
const tab: Tab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as Tab) : "overview";
|
||||
const selfHref = `/work-orders/${wo.id}${tab === "overview" ? "" : `?tab=${tab}`}`;
|
||||
const withParam = (k: string, v: string) => `/work-orders/${wo.id}?${tab === "overview" ? "" : `tab=${tab}&`}${k}=${v}`;
|
||||
|
||||
const transitions = availableTransitions(ctx, wo.status);
|
||||
const canAssign = can("work_order:assign") && !PLANNING_LOCKED.includes(wo.status);
|
||||
const canWrite = can("work_order:write") && !FINAL_STATUSES.includes(wo.status);
|
||||
const canEditPlanning = can("work_order:write") && !PLANNING_LOCKED.includes(wo.status);
|
||||
const assignFirst = canAssign && ["draft", "review_required", "planned"].includes(wo.status);
|
||||
|
||||
// Primary = furthest forward step (never cancel/backward); assignment goes through the popup.
|
||||
const forward = transitions.filter((to) => to !== "cancelled" && idx(to) > idx(wo.status) && !(assignFirst && to === "assigned"));
|
||||
const primary = assignFirst ? null : forward.sort((a, b) => idx(b) - idx(a))[0] ?? null;
|
||||
const secondary = transitions.filter((to) => to !== primary && to !== "cancelled" && !(assignFirst && to === "assigned"));
|
||||
|
||||
const label = (to: WorkOrderStatus) =>
|
||||
wo.status === "in_review" && to === "in_progress"
|
||||
? t("transition.correction")
|
||||
: wo.status === "released_for_billing" && to === "in_review"
|
||||
? t("transition.revoke")
|
||||
: t(`transition.to.${to}`);
|
||||
|
||||
const transitionButton = (to: WorkOrderStatus, variant: "primary" | "outline" | "danger") =>
|
||||
reasonRequired(wo.status, to) ? (
|
||||
<Link key={to} href={withParam("transition", to)} scroll={false} className={buttonCls(variant)}>
|
||||
{label(to)}
|
||||
</Link>
|
||||
) : (
|
||||
<ActionForm key={to} action={transitionAction} submitLabel={label(to)} pendingLabel={t("transition.pending")} variant={variant} footerClassName="mt-0">
|
||||
<input type="hidden" name="workOrderId" value={wo.id} />
|
||||
<input type="hidden" name="to" value={to} />
|
||||
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||
<input type="hidden" name="returnTab" value={tab} />
|
||||
</ActionForm>
|
||||
);
|
||||
|
||||
// Popups
|
||||
const reasonTo = one(sp.transition) as WorkOrderStatus | undefined;
|
||||
const showReason = reasonTo && transitions.includes(reasonTo) && reasonRequired(wo.status, reasonTo);
|
||||
const showAssign = canAssign && one(sp.assign) === "1";
|
||||
const showEdit = canWrite && one(sp.edit) === "1";
|
||||
const [teams, users, editData, orderTypes] = await Promise.all([
|
||||
showAssign ? teamOptions(ctx) : Promise.resolve([]),
|
||||
showAssign ? userOptions(ctx) : Promise.resolve([]),
|
||||
showEdit ? customerOption(ctx, wo.customerId) : Promise.resolve(null),
|
||||
showEdit ? listOrderTypes(ctx) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const billingTransitions = transitions.filter((to) => ["released_for_billing", "billed"].includes(to) || (wo.status === "in_review" && to === "in_progress") || (wo.status === "released_for_billing" && to === "in_review"));
|
||||
|
||||
const tabProps = { ctx, wo, locale, tz, canEdit: canEditPlanning };
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("detail.back")}
|
||||
</Link>
|
||||
|
||||
<header className="shadow-card mt-1 rounded-xl border border-l-4 bg-card p-4 md:p-5" style={{ borderLeftColor: "var(--ui-primary)" }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{wo.number}</span>
|
||||
<span>· {t("detail.version", { version: wo.version })}</span>
|
||||
{wo.isEmergency && (
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-[var(--risk)]">
|
||||
<Siren className="size-3.5" aria-hidden />
|
||||
{t("emergency")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<h1 className="mt-1 text-[22px] break-words">{wo.title}</h1>
|
||||
<p className="mt-1 text-sm">
|
||||
{customerDisplayName(wo.customer)}
|
||||
{wo.site && <span className="text-muted-foreground"> · {wo.site.name}</span>}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<StatusBadge status={wo.status} label={t(`status.${wo.status}`)} className="text-sm" />
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<UsersRound className="size-3.5" aria-hidden />
|
||||
{wo.team?.name ?? t("unassigned")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 sm:items-end">
|
||||
<span className="text-xs font-semibold text-muted-foreground">{t("detail.nextStep")}</span>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||
{assignFirst && (
|
||||
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("primary")}>
|
||||
{t("detail.assign")}
|
||||
</Link>
|
||||
)}
|
||||
{primary && transitionButton(primary, "primary")}
|
||||
{!assignFirst && !primary && <span className="text-sm text-muted-foreground">{t("detail.noActions")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
{secondary.map((to) => transitionButton(to, "outline"))}
|
||||
{canAssign && !assignFirst && (
|
||||
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("outline")}>
|
||||
<UsersRound className="size-4" aria-hidden />
|
||||
{t("detail.reassign")}
|
||||
</Link>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Link href={withParam("edit", "1")} scroll={false} className={buttonCls("outline")}>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
{t("detail.edit")}
|
||||
</Link>
|
||||
)}
|
||||
{transitions.includes("cancelled") && (
|
||||
<Link href={withParam("transition", "cancelled")} scroll={false} className={`${buttonCls("danger")} sm:ml-auto`}>
|
||||
{t("detail.cancel")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-4">
|
||||
<LinkTabs
|
||||
label={t("detail.tabs.overview")}
|
||||
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{tab === "overview" && <OverviewTab {...tabProps} />}
|
||||
{tab === "checklist" && <ChecklistTab {...tabProps} />}
|
||||
{tab === "material" && <MaterialTab {...tabProps} />}
|
||||
{tab === "times" && <TimesTab {...tabProps} />}
|
||||
{tab === "photos" && <PhotosTab {...tabProps} />}
|
||||
{tab === "notes" && <NotesTab {...tabProps} />}
|
||||
{tab === "reports" && (
|
||||
<ReportsTab
|
||||
{...tabProps}
|
||||
billing={
|
||||
billingTransitions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{billingTransitions.map((to) => transitionButton(to, to === "released_for_billing" || to === "billed" ? "primary" : "outline"))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
|
||||
{tab === "history" && <HistoryTab {...tabProps} />}
|
||||
</div>
|
||||
|
||||
{showReason && reasonTo && (
|
||||
<Modal title={label(reasonTo)} sub={t("transition.reasonTitle")} closeHref={selfHref} closeLabel={tc("close")}>
|
||||
<ActionForm action={transitionAction} submitLabel={t("transition.confirm")} pendingLabel={t("transition.pending")} variant={reasonTo === "cancelled" ? "danger" : "default"} className="p-5">
|
||||
<input type="hidden" name="workOrderId" value={wo.id} />
|
||||
<input type="hidden" name="to" value={reasonTo} />
|
||||
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||
<input type="hidden" name="returnTab" value={tab} />
|
||||
<Field label={`${t("transition.reason")} *`} htmlFor="tr-reason">
|
||||
<textarea id="tr-reason" name="reason" required rows={4} maxLength={2000} placeholder={t("transition.reasonPlaceholder")} className={`${inputCls} py-2`} autoFocus />
|
||||
</Field>
|
||||
</ActionForm>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showAssign && (
|
||||
<Modal title={t("assign.title")} sub={t("assign.sub")} closeHref={selfHref} closeLabel={tc("close")}>
|
||||
{teams.length === 0 ? (
|
||||
<p className="p-5 text-sm text-muted-foreground">{t("assign.noTeams")}</p>
|
||||
) : (
|
||||
<ActionForm action={assignAction} submitLabel={t("assign.submit")} pendingLabel={t("assign.pending")} variant="primary" className="grid gap-4 p-5 md:grid-cols-2">
|
||||
<input type="hidden" name="workOrderId" value={wo.id} />
|
||||
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||
<Field label={`${t("assign.team")} *`} htmlFor="as-team">
|
||||
<select id="as-team" name="teamId" required defaultValue={wo.team?.id ?? ""} className={inputCls}>
|
||||
<option value="">{t("assign.chooseTeam")}</option>
|
||||
{teams.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>
|
||||
{tm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("assign.teamLead")} htmlFor="as-lead">
|
||||
<select id="as-lead" name="teamLeadUserId" defaultValue={wo.teamLead?.id ?? ""} className={inputCls}>
|
||||
<option value="">{t("assign.teamLeadDefault")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<fieldset className="md:col-span-2">
|
||||
<legend className="mb-1 text-[13px] font-semibold">{t("assign.members")}</legend>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{users.map((u) => (
|
||||
<label key={u.id} className="flex min-h-11 items-center gap-2.5 text-sm">
|
||||
<input type="checkbox" name="userIds" value={u.id} defaultChecked={wo.assignees.some((a) => a.user.id === u.id)} className="size-5 accent-[var(--ui-primary)]" />
|
||||
{u.name}
|
||||
<span className="text-xs text-muted-foreground">{teams.filter((tm) => tm.members.some((m) => m.user.id === u.id)).map((tm) => tm.name).join(", ")}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</ActionForm>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showEdit && editData && (
|
||||
<Modal title={t("edit.title")} sub={`${wo.number} · ${customerDisplayName(wo.customer)}`} closeHref={selfHref} closeLabel={tc("close")}>
|
||||
<ActionForm action={updateWorkOrderAction.bind(null, wo.id)} submitLabel={t("edit.submit")} pendingLabel={t("edit.pending")} className="p-5">
|
||||
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||
<WorkOrderFields
|
||||
mode="edit"
|
||||
tz={tz}
|
||||
sites={editData.sites.map((s) => ({ id: s.id, name: [s.name, s.city].filter(Boolean).join(" · ") }))}
|
||||
contacts={editData.contacts.map((c) => ({ id: c.id, name: c.name }))}
|
||||
orderTypes={orderTypes.map((o) => ({ id: o.id, name: o.active ? o.name : `${o.name} (–)` }))}
|
||||
defaults={{
|
||||
title: wo.title,
|
||||
siteId: wo.siteId,
|
||||
contactId: wo.contactId,
|
||||
orderTypeId: wo.orderTypeId,
|
||||
priority: wo.priority,
|
||||
plannedStart: wo.plannedStart,
|
||||
plannedEnd: wo.plannedEnd,
|
||||
description: wo.description,
|
||||
scope: wo.scope,
|
||||
internalNotes: wo.internalNotes,
|
||||
technicianNotes: wo.technicianNotes,
|
||||
signatureRequired: wo.signatureRequired,
|
||||
billingType: wo.billingType,
|
||||
externalOrderNumber: wo.externalOrderNumber,
|
||||
offerNumber: wo.offerNumber,
|
||||
}}
|
||||
/>
|
||||
</ActionForm>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ActionForm } from "@/components/work-orders/action-form";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { Empty, Section, StatusBadge } from "@/components/work-orders/ui";
|
||||
import { formatDateTime } from "@/lib/work-orders/time";
|
||||
import { applySyncConflictAction, discardSyncConflictAction } from "@/server/actions/work_orders/work-orders";
|
||||
import { listSyncConflicts } from "@/server/services/work-orders/conflicts";
|
||||
|
||||
/** Offline sync conflicts (ARCHITEKTUR §4.6) — apply against current state or discard. */
|
||||
export default async function SyncConflictsPage() {
|
||||
const { ctx, locale, tz, can } = await pageContext();
|
||||
if (!can("work_order:write")) redirect("/work-orders");
|
||||
const t = await getTranslations("workOrders");
|
||||
const conflicts = await listSyncConflicts(ctx);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("detail.back")}
|
||||
</Link>
|
||||
<PageHead crumb={t("conflicts.crumb")} title={t("conflicts.title")} sub={t("conflicts.sub")} />
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t("conflicts.applyHint")} {t("conflicts.stubHint")}
|
||||
</p>
|
||||
|
||||
{conflicts.length === 0 ? (
|
||||
<Empty>{t("conflicts.empty")}</Empty>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{conflicts.map((c) => {
|
||||
const payload = JSON.stringify(c.payload, null, 2);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<Section
|
||||
title={`${c.opType}`}
|
||||
actions={<span className="text-xs text-muted-foreground">{t("conflicts.received")}: {formatDateTime(c.receivedAt, locale, tz)}</span>}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("conflicts.user")}: </span>
|
||||
{c.userName ?? c.userId}
|
||||
</p>
|
||||
{c.workOrder && (
|
||||
<p className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("conflicts.entity")}: </span>
|
||||
<Link href={`/work-orders/${c.workOrder.id}`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||
{c.workOrder.number} · {c.workOrder.title}
|
||||
</Link>
|
||||
<StatusBadge status={c.workOrder.status} label={t(`status.${c.workOrder.status}`)} />
|
||||
</p>
|
||||
)}
|
||||
{c.baseVersion !== null && c.workOrder && (
|
||||
<p className="text-xs font-semibold text-[var(--warn)]">{t("conflicts.baseVersion", { base: c.baseVersion, current: c.workOrder.version })}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<ActionForm action={applySyncConflictAction} submitLabel={t("conflicts.apply")} variant="primary" successText={t("conflicts.applied")} footerClassName="mt-0">
|
||||
<input type="hidden" name="opId" value={c.id} />
|
||||
</ActionForm>
|
||||
<ActionForm action={discardSyncConflictAction} submitLabel={t("conflicts.discard")} variant="outline" successText={t("conflicts.discarded")} footerClassName="mt-0">
|
||||
<input type="hidden" name="opId" value={c.id} />
|
||||
</ActionForm>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold text-muted-foreground">{t("conflicts.payload")}</p>
|
||||
<pre className="max-h-56 overflow-auto rounded-lg bg-muted p-3 text-xs">{payload.length > 2000 ? `${payload.slice(0, 2000)}…` : payload}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,353 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { LayoutGrid, Plus, RefreshCw, Rows3, X } from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { Empty, Field, GroupIcon, inputCls, LinkTabs } from "@/components/work-orders/ui";
|
||||
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
|
||||
import { WorkOrderCards, WorkOrderTable } from "@/components/work-orders/work-order-list";
|
||||
import { isoDay, parseListParams, SORT_FIELDS, STATUS_GROUPS, toQuery, type ListParams } from "@/lib/work-orders/filters";
|
||||
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
||||
import { createWorkOrderAction } from "@/server/actions/work_orders/work-orders";
|
||||
import { listWorkOrders } from "@/server/services/work-orders/list";
|
||||
import {
|
||||
customerDisplayName,
|
||||
customerFilterOptions,
|
||||
customerOption,
|
||||
searchCustomerOptions,
|
||||
siteFilterOptions,
|
||||
teamOptions,
|
||||
userOptions,
|
||||
} from "@/server/services/work-orders/options";
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="work_orders" />;
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
|
||||
function filterQuery(p: ListParams, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
q: p.q,
|
||||
from: p.from,
|
||||
to: p.to,
|
||||
customerId: p.customerId,
|
||||
siteId: p.siteId,
|
||||
teamId: p.teamId,
|
||||
userId: p.userId,
|
||||
status: p.statuses,
|
||||
group: p.group,
|
||||
orderTypeId: p.orderTypeId,
|
||||
priority: p.priority,
|
||||
preset: p.preset,
|
||||
sort: p.sort === "plannedStart" ? undefined : p.sort,
|
||||
dir: p.dir === "asc" ? undefined : p.dir,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** Backoffice work order list (§21 filters, status group tabs, cards/table, create popup). */
|
||||
export default async function WorkOrdersPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const { ctx, locale, tz, can } = await pageContext();
|
||||
const t = await getTranslations("workOrders");
|
||||
const td = await getTranslations("dashboard");
|
||||
const tc = await getTranslations("common");
|
||||
const sp = await searchParams;
|
||||
const p = parseListParams(sp);
|
||||
const view = one(sp.view) === "table" ? "table" : "cards";
|
||||
const readAll = can("work_order:read_all");
|
||||
|
||||
const [result, teams, users, orderTypes, customers, sites] = await Promise.all([
|
||||
listWorkOrders(ctx, p),
|
||||
teamOptions(ctx),
|
||||
readAll ? userOptions(ctx) : Promise.resolve([]),
|
||||
listOrderTypes(ctx),
|
||||
readAll ? customerFilterOptions(ctx) : Promise.resolve([]),
|
||||
p.customerId ? siteFilterOptions(ctx, p.customerId) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const base = filterQuery(p, { view: view === "table" ? "table" : undefined });
|
||||
const pages = Math.max(1, Math.ceil(result.total / p.pageSize));
|
||||
const allCount = Object.values(result.groupCounts).reduce((a, b) => a + b, 0);
|
||||
const listHref = `/work-orders${toQuery(base)}`;
|
||||
|
||||
// ---- create popup ----
|
||||
const canCreate = can("work_order:write");
|
||||
const showNew = canCreate && one(sp.new) === "1";
|
||||
const newCustomerId = one(sp.customerId_new);
|
||||
const cq = one(sp.cq);
|
||||
const [customerHits, chosen, activeTypes] = showNew
|
||||
? await Promise.all([
|
||||
newCustomerId ? Promise.resolve([]) : searchCustomerOptions(ctx, cq),
|
||||
newCustomerId ? customerOption(ctx, newCustomerId) : Promise.resolve(null),
|
||||
listOrderTypes(ctx, { activeOnly: true }),
|
||||
])
|
||||
: [[], null, []];
|
||||
const newBase = { ...base, new: "1" };
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{can("work_order:write") && (
|
||||
<Link href="/work-orders/conflicts" className={buttonCls("outline")}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
{t("conflictsLink")}
|
||||
</Link>
|
||||
)}
|
||||
{canCreate && (
|
||||
<Link href={`/work-orders${toQuery(newBase)}`} className={buttonCls("primary")} scroll={false}>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("new")}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<LinkTabs
|
||||
label={t("filter.status")}
|
||||
items={[
|
||||
{ href: `/work-orders${toQuery({ ...base, group: undefined, status: undefined })}`, label: t("allGroups"), active: !p.group && !p.statuses, count: allCount },
|
||||
...STATUS_GROUPS.map((g) => ({
|
||||
href: `/work-orders${toQuery({ ...base, group: g, status: undefined })}`,
|
||||
label: t(`statusGroup.${g}`),
|
||||
active: p.group === g,
|
||||
count: result.groupCounts[g],
|
||||
icon: <GroupIcon group={g} className="size-4" />,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
|
||||
<details className="shadow-card mt-4 rounded-xl border bg-card" open={Boolean(p.q || p.from || p.to || p.customerId || p.teamId || p.userId || p.statuses || p.orderTypeId || p.priority)}>
|
||||
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filter.title")}</summary>
|
||||
<form method="get" action="/work-orders" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{p.group && <input type="hidden" name="group" value={p.group} />}
|
||||
{p.preset && <input type="hidden" name="preset" value={p.preset} />}
|
||||
{view === "table" && <input type="hidden" name="view" value="table" />}
|
||||
<Field label={t("filter.q")} htmlFor="f-q" className="sm:col-span-2">
|
||||
<input id="f-q" type="search" name="q" defaultValue={p.q ?? ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("filter.from")} htmlFor="f-from">
|
||||
<input id="f-from" type="date" name="from" defaultValue={p.from ? isoDay(p.from) : ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("filter.to")} htmlFor="f-to">
|
||||
<input id="f-to" type="date" name="to" defaultValue={p.to ? isoDay(p.to) : ""} className={inputCls} />
|
||||
</Field>
|
||||
{readAll && (
|
||||
<Field label={t("filter.customer")} htmlFor="f-customer">
|
||||
<select id="f-customer" name="customerId" defaultValue={p.customerId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{customerDisplayName(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{sites.length > 0 && (
|
||||
<Field label={t("filter.site")} htmlFor="f-site">
|
||||
<select id="f-site" name="siteId" defaultValue={p.siteId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{sites.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("filter.team")} htmlFor="f-team">
|
||||
<select id="f-team" name="teamId" defaultValue={p.teamId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{teams.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>
|
||||
{tm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{readAll && (
|
||||
<Field label={t("filter.user")} htmlFor="f-user">
|
||||
<select id="f-user" name="userId" defaultValue={p.userId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("filter.status")} htmlFor="f-status">
|
||||
<select id="f-status" name="status" defaultValue={p.statuses?.[0] ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{WORK_ORDER_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filter.orderType")} htmlFor="f-type">
|
||||
<select id="f-type" name="orderTypeId" defaultValue={p.orderTypeId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{orderTypes.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filter.priority")} htmlFor="f-prio">
|
||||
<select id="f-prio" name="priority" defaultValue={p.priority ?? ""} className={inputCls}>
|
||||
<option value="">{t("filter.any")}</option>
|
||||
{WORK_ORDER_PRIORITIES.map((pr) => (
|
||||
<option key={pr} value={pr}>
|
||||
{t(`priority.${pr}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filter.sort")} htmlFor="f-sort">
|
||||
<div className="flex gap-2">
|
||||
<select id="f-sort" name="sort" defaultValue={p.sort} className={inputCls}>
|
||||
{SORT_FIELDS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`sort.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="dir" aria-label={t("filter.sort")} defaultValue={p.dir} className={inputCls}>
|
||||
<option value="asc">{t("sort.asc")}</option>
|
||||
<option value="desc">{t("sort.desc")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-4">
|
||||
<button type="submit" className={buttonCls("default")}>
|
||||
{t("filter.apply")}
|
||||
</button>
|
||||
<Link href="/work-orders" className={buttonCls("ghost")}>
|
||||
{t("filter.reset")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className="font-semibold">{t("resultCount", { count: result.total })}</span>
|
||||
{p.preset && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold">
|
||||
{t("presetActive", { name: td(`tiles.${p.preset}`) })}
|
||||
<Link href={`/work-orders${toQuery({ ...base, preset: undefined })}`} aria-label={t("removePreset")} className="inline-flex size-6 items-center justify-center rounded-full hover:bg-muted">
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1" role="group" aria-label={t("view.cards")}>
|
||||
<Link href={`/work-orders${toQuery({ ...base, view: undefined, page: p.page > 1 ? p.page : undefined })}`} aria-current={view === "cards" ? "true" : undefined} className={buttonCls(view === "cards" ? "default" : "outline")}>
|
||||
<LayoutGrid className="size-4" aria-hidden />
|
||||
{t("view.cards")}
|
||||
</Link>
|
||||
<Link href={`/work-orders${toQuery({ ...base, view: "table", page: p.page > 1 ? p.page : undefined })}`} aria-current={view === "table" ? "true" : undefined} className={buttonCls(view === "table" ? "default" : "outline")}>
|
||||
<Rows3 className="size-4" aria-hidden />
|
||||
{t("view.table")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{result.items.length === 0 ? (
|
||||
<Empty>{t("empty")}</Empty>
|
||||
) : view === "table" ? (
|
||||
<WorkOrderTable items={result.items} locale={locale} tz={tz} />
|
||||
) : (
|
||||
<WorkOrderCards items={result.items} locale={locale} tz={tz} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pages > 1 && (
|
||||
<nav aria-label={t("pagination.page", { page: p.page, pages })} className="mt-4 flex items-center justify-center gap-3 text-sm">
|
||||
{p.page > 1 && (
|
||||
<Link href={`/work-orders${toQuery({ ...base, page: p.page - 1 })}`} className={buttonCls("outline")}>
|
||||
{t("pagination.prev")}
|
||||
</Link>
|
||||
)}
|
||||
<span>{t("pagination.page", { page: p.page, pages })}</span>
|
||||
{p.page < pages && (
|
||||
<Link href={`/work-orders${toQuery({ ...base, page: p.page + 1 })}`} className={buttonCls("outline")}>
|
||||
{t("pagination.next")}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{showNew && (
|
||||
<Modal title={t("create.title")} sub={t("create.sub")} closeHref={listHref} closeLabel={tc("close")}>
|
||||
{!chosen ? (
|
||||
<div className="p-5">
|
||||
<h3 className="mb-2 font-heading text-sm font-semibold">{t("create.stepCustomer")}</h3>
|
||||
<form method="get" action="/work-orders" className="flex flex-wrap gap-2">
|
||||
<input type="hidden" name="new" value="1" />
|
||||
<input type="search" name="cq" defaultValue={cq ?? ""} aria-label={t("create.customerSearch")} placeholder={t("create.customerSearch")} className={`${inputCls} max-w-md flex-1`} autoFocus />
|
||||
<button type="submit" className={buttonCls("default")}>
|
||||
{t("create.searchButton")}
|
||||
</button>
|
||||
</form>
|
||||
{customerHits.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("create.noCustomers")}</p>
|
||||
) : (
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{customerHits.map((c) => (
|
||||
<li key={c.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2">
|
||||
<div>
|
||||
<p className="font-semibold">{customerDisplayName(c)}</p>
|
||||
<p className="text-xs text-muted-foreground">{[c.customerNumber, c.city].filter(Boolean).join(" · ")}</p>
|
||||
</div>
|
||||
<Link href={`/work-orders?new=1&customerId_new=${c.id}`} scroll={false} className={buttonCls("outline")}>
|
||||
{t("create.chooseCustomer")}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-lg bg-muted/50 px-3 py-2">
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">{t("fields.customer")}: </span>
|
||||
<span className="font-semibold">{customerDisplayName(chosen.customer)}</span>
|
||||
</p>
|
||||
<Link href="/work-orders?new=1" scroll={false} className="text-sm font-semibold text-[var(--primary)] hover:underline">
|
||||
{t("create.changeCustomer")}
|
||||
</Link>
|
||||
</div>
|
||||
<h3 className="mb-3 font-heading text-sm font-semibold">{t("create.stepDetails")}</h3>
|
||||
<ActionForm action={createWorkOrderAction} submitLabel={t("create.submit")} pendingLabel={t("create.pending")} variant="primary">
|
||||
<input type="hidden" name="customerId" value={chosen.customer.id} />
|
||||
<WorkOrderFields
|
||||
mode="create"
|
||||
tz={tz}
|
||||
sites={chosen.sites.map((s) => ({ id: s.id, name: [s.name, [s.street, s.houseNumber].filter(Boolean).join(" "), s.city].filter(Boolean).join(" · ") }))}
|
||||
contacts={chosen.contacts.map((c) => ({ id: c.id, name: c.role ? `${c.name} (${c.role})` : c.name }))}
|
||||
orderTypes={activeTypes.map((o) => ({ id: o.id, name: o.name }))}
|
||||
defaults={{ siteId: chosen.sites.length === 1 ? chosen.sites[0].id : null }}
|
||||
/>
|
||||
</ActionForm>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user