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:
2026-09-14 12:30:32 +02:00
co-authored by Claude Opus 5
parent a962fa8be9
commit b96016d593
23 changed files with 3545 additions and 34 deletions
+122
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}