Merge lane/auftraege in feature/craftvia-mvp
Konflikte gelöst: Header mit Suche (L2) und Glocke (L6), Audit-Labels vereinigt (ohne doppeltes sync_operation), Navigation mit Benachrichtigungen und Auftragsvorlagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,9 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
document: "Dokument",
|
||||
checklist_item: "Checklistenpunkt",
|
||||
activity_note: "Tätigkeitsnotiz",
|
||||
order_type: "Auftragsart",
|
||||
checklist_template: "Checklisten-Vorlage",
|
||||
number_sequence: "Nummernkreis",
|
||||
};
|
||||
|
||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||
|
||||
const VARIANTS = {
|
||||
primary: "bg-cta text-cta-foreground hover:opacity-90",
|
||||
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||
outline: "border border-border bg-background hover:bg-muted",
|
||||
danger: "border border-[var(--risk)] bg-background text-[var(--risk)] hover:bg-muted",
|
||||
ghost: "hover:bg-muted text-muted-foreground",
|
||||
} as const;
|
||||
|
||||
export const buttonCls = (variant: keyof typeof VARIANTS = "default") =>
|
||||
cn(
|
||||
"inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-4 font-heading text-sm font-semibold transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:opacity-50",
|
||||
VARIANTS[variant],
|
||||
);
|
||||
|
||||
/**
|
||||
* Form bound to a work order server action. Shows translated errors (incl. structured
|
||||
* completion blockers) and an optional success note. Children are server-rendered fields.
|
||||
*/
|
||||
export function ActionForm({
|
||||
action,
|
||||
children,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
successText,
|
||||
variant = "default",
|
||||
namespace = "workOrders",
|
||||
className,
|
||||
footerClassName,
|
||||
hideSubmit,
|
||||
}: {
|
||||
action: Action;
|
||||
children?: React.ReactNode;
|
||||
submitLabel: string;
|
||||
pendingLabel?: string;
|
||||
successText?: string;
|
||||
variant?: keyof typeof VARIANTS;
|
||||
namespace?: "workOrders" | "settingsTemplates";
|
||||
className?: string;
|
||||
footerClassName?: string;
|
||||
hideSubmit?: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||
const t = useTranslations(namespace);
|
||||
const tw = useTranslations("workOrders");
|
||||
|
||||
let errorText = "";
|
||||
if (state.status === "error") {
|
||||
const key = `errors.${state.message}`;
|
||||
errorText = t.has(key) ? t(key) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal");
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className={className}>
|
||||
{children}
|
||||
{state.status === "error" && (
|
||||
<div role="alert" className="mt-3 rounded-lg border border-[var(--risk)] bg-card px-3 py-2 text-sm text-[var(--risk)]">
|
||||
<p className="flex items-center gap-2 font-semibold">
|
||||
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||
{errorText}
|
||||
</p>
|
||||
{state.blockers && state.blockers.length > 0 && (
|
||||
<ul className="mt-1.5 list-disc space-y-0.5 pl-6 text-foreground">
|
||||
{state.blockers.map((b, i) => (
|
||||
<li key={i}>{blockerText(tw, b)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === "ok" && successText && (
|
||||
<p role="status" className="mt-3 flex items-center gap-2 text-sm text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{successText}
|
||||
</p>
|
||||
)}
|
||||
{!hideSubmit && (
|
||||
<div className={cn("mt-3 flex flex-wrap gap-2", footerClassName)}>
|
||||
<button type="submit" disabled={pending} className={buttonCls(variant)}>
|
||||
{pending ? pendingLabel ?? submitLabel : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function blockerText(t: ReturnType<typeof useTranslations>, b: CompletionBlocker): string {
|
||||
switch (b.kind) {
|
||||
case "checklist_item":
|
||||
return t("blockers.checklist_item", { label: b.label });
|
||||
case "photo_requirement":
|
||||
return t("blockers.photo_requirement", { label: b.label });
|
||||
case "running_session":
|
||||
return t("blockers.running_session");
|
||||
case "missing_field":
|
||||
return t.has(`blockers.field.${b.field}`) ? t(`blockers.field.${b.field}`) : b.field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Camera, Download, FileText } from "lucide-react";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import {
|
||||
getChecklistTab,
|
||||
getDocumentsTab,
|
||||
getHistoryTab,
|
||||
getMaterialOverview,
|
||||
getNotesTab,
|
||||
getPhotosTab,
|
||||
getReportsTab,
|
||||
getTimesTab,
|
||||
type WorkOrderDetail,
|
||||
} from "@/server/services/work-orders/detail";
|
||||
import { UPLOAD_CATEGORIES } from "@/server/services/work-orders/documents";
|
||||
import { checklistTemplateOptions, customerDisplayName } from "@/server/services/work-orders/options";
|
||||
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
|
||||
import {
|
||||
addChecklistItemAction,
|
||||
addMaterialPlanAction,
|
||||
addPhotoRequirementAction,
|
||||
applyChecklistTemplateAction,
|
||||
archiveDocumentAction,
|
||||
removeChecklistItemAction,
|
||||
removeMaterialPlanAction,
|
||||
removePhotoRequirementAction,
|
||||
} from "@/server/actions/work_orders/work-orders";
|
||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
|
||||
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
|
||||
type TabProps = { ctx: ServiceCtx; wo: WorkOrderDetail; locale: string; tz: string; canEdit: boolean };
|
||||
|
||||
const Hidden = ({ name, value }: { name: string; value: string }) => <input type="hidden" name={name} value={value} />;
|
||||
|
||||
function address(x: { street?: string | null; houseNumber?: string | null; postalCode?: string | null; city?: string | null } | null) {
|
||||
if (!x) return "";
|
||||
return [[x.street, x.houseNumber].filter(Boolean).join(" "), [x.postalCode, x.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
export async function OverviewTab({ wo, locale, tz }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Section title={t("overview.customer")}>
|
||||
<Dl
|
||||
rows={[
|
||||
[t("overview.customer"), <Link key="c" href={`/customers/${wo.customer.id}`} className="font-semibold text-[var(--primary)] hover:underline">{customerDisplayName(wo.customer)}</Link>],
|
||||
[t("overview.site"), wo.site ? <Link key="s" href={`/sites/${wo.site.id}`} className="text-[var(--primary)] hover:underline">{[wo.site.name, address(wo.site)].filter(Boolean).join(" · ")}</Link> : null],
|
||||
[t("overview.contact"), wo.contact ? [wo.contact.name, wo.contact.phone ?? wo.contact.mobile, wo.contact.email].filter(Boolean).join(" · ") : null],
|
||||
[t("overview.accessNotes"), wo.site?.accessNotes ?? null],
|
||||
[t("overview.safetyNotes"), wo.site?.safetyNotes ?? null],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t("overview.planning")}>
|
||||
<Dl
|
||||
rows={[
|
||||
[t("fields.orderType"), wo.orderType?.name ?? null],
|
||||
[t("fields.priority"), t(`priority.${wo.priority}`)],
|
||||
[t("fields.plannedStart"), formatDateTime(wo.plannedStart, locale, tz) || t("noDate")],
|
||||
[t("fields.plannedEnd"), formatDateTime(wo.plannedEnd, locale, tz)],
|
||||
[t("fields.signatureRequired"), wo.signatureRequired ? t("fields.yes") : t("fields.no")],
|
||||
[t("fields.billingType"), wo.billingType ? t(`billingType.${wo.billingType}`) : null],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t("overview.assignment")}>
|
||||
<Dl
|
||||
rows={[
|
||||
[t("overview.team"), wo.team?.name ?? t("unassigned")],
|
||||
[t("overview.teamLead"), wo.teamLead?.name ?? null],
|
||||
[t("overview.assignees"), wo.assignees.map((a) => a.user.name).join(", ")],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t("overview.order")}>
|
||||
<Dl
|
||||
rows={[
|
||||
[t("fields.externalOrderNumber"), wo.externalOrderNumber],
|
||||
[t("fields.offerNumber"), wo.offerNumber],
|
||||
[t("overview.created"), formatDateTime(wo.createdAt, locale, tz)],
|
||||
[t("overview.updated"), formatDateTime(wo.updatedAt, locale, tz)],
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
<Section title={t("fields.description")} className="lg:col-span-2">
|
||||
<p className="text-sm whitespace-pre-wrap">{wo.description || "—"}</p>
|
||||
<h3 className="mt-4 mb-1 font-heading text-sm font-semibold">{t("fields.scope")}</h3>
|
||||
<p className="text-sm whitespace-pre-wrap">{wo.scope || "—"}</p>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ChecklistTab({ ctx, wo, canEdit }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const [{ items, requirements, blockers }, templates] = await Promise.all([getChecklistTab(ctx, wo.id), canEdit ? checklistTemplateOptions(ctx) : Promise.resolve([])]);
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<BlockerSummary blockers={blockers} />
|
||||
<Section title={t("checklist.items")}>
|
||||
{items.length === 0 ? (
|
||||
<Empty>{t("checklist.empty")}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{items.map((i) => (
|
||||
<li key={i.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
<span className={i.checked ? "text-[var(--ok)]" : "text-muted-foreground"}>{i.checked ? `✓ ${t("checklist.done")}` : `○ ${t("checklist.open")}`}</span> · {i.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{i.required ? t("checklist.required") : t("checklist.optional")}
|
||||
{i.requiresPhoto && ` · ${t("checklist.requiresPhoto")} (${t("checklist.photoCount", { count: i._count.photos })})`}
|
||||
{i.comment && ` · ${i.comment}`}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && !i.checked && (
|
||||
<ActionForm action={removeChecklistItemAction} submitLabel={t("checklist.remove")} variant="ghost" footerClassName="mt-0">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<Hidden name="itemId" value={i.id} />
|
||||
</ActionForm>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{canEdit && (
|
||||
<ActionForm action={addChecklistItemAction} submitLabel={t("checklist.addItem")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<Field label={t("checklist.label")} htmlFor="cl-label">
|
||||
<input id="cl-label" name="label" required maxLength={200} className={inputCls} />
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Check name="required" id="cl-req" label={t("checklist.required")} />
|
||||
<Check name="requiresPhoto" id="cl-photo" label={t("checklist.requiresPhoto")} />
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
</Section>
|
||||
<Section title={t("checklist.photos")}>
|
||||
{requirements.length === 0 ? (
|
||||
<Empty>{t("checklist.emptyPhotos")}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{requirements.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<p className="text-sm">
|
||||
<span className={r._count.photos > 0 ? "font-semibold text-[var(--ok)]" : "font-semibold text-[var(--warn)]"}>
|
||||
{r._count.photos > 0 ? "✓" : "!"} {t("checklist.photoCount", { count: r._count.photos })}
|
||||
</span>{" "}
|
||||
· {r.label}
|
||||
</p>
|
||||
{canEdit && r._count.photos === 0 && (
|
||||
<ActionForm action={removePhotoRequirementAction} submitLabel={t("checklist.remove")} variant="ghost" footerClassName="mt-0">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<Hidden name="requirementId" value={r.id} />
|
||||
</ActionForm>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<ActionForm action={addPhotoRequirementAction} submitLabel={t("checklist.addPhoto")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<Field label={t("checklist.label")} htmlFor="pr-label">
|
||||
<input id="pr-label" name="label" required maxLength={200} className={inputCls} />
|
||||
</Field>
|
||||
</ActionForm>
|
||||
{templates.length > 0 && (
|
||||
<ActionForm action={applyChecklistTemplateAction} submitLabel={t("checklist.applyButton")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<Field label={t("checklist.applyTemplate")} htmlFor="tpl">
|
||||
<select id="tpl" name="templateId" className={inputCls}>
|
||||
{templates.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</ActionForm>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function BlockerSummary({ blockers }: { blockers: CompletionBlocker[] }) {
|
||||
const t = await getTranslations("workOrders");
|
||||
if (blockers.length === 0) {
|
||||
return <p className="rounded-lg border border-[var(--ok)] bg-card px-4 py-2 text-sm font-semibold text-[var(--ok)] lg:col-span-2">✓ {t("checklist.complete")}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm lg:col-span-2" role="status">
|
||||
<p className="font-semibold text-[var(--warn)]">! {t("detail.blockersTitle")}</p>
|
||||
<ul className="mt-1 list-disc pl-6">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i}>
|
||||
{b.kind === "checklist_item"
|
||||
? t("blockers.checklist_item", { label: b.label })
|
||||
: b.kind === "photo_requirement"
|
||||
? t("blockers.photo_requirement", { label: b.label })
|
||||
: b.kind === "running_session"
|
||||
? t("blockers.running_session")
|
||||
: t(`blockers.field.${b.field}` as "blockers.field.signature")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function MaterialTab({ ctx, wo, locale, canEdit }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const rows = await getMaterialOverview(ctx, wo.id);
|
||||
const nf = new Intl.NumberFormat(locale === "en" ? "en-GB" : "de-DE", { maximumFractionDigits: 3 });
|
||||
return (
|
||||
<Section title={t("material.title")}>
|
||||
{rows.length === 0 ? (
|
||||
<Empty>{t("material.empty")}</Empty>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead className="border-b text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 pr-3">{t("material.name")}</th>
|
||||
<th className="py-2 pr-3 text-right">{t("material.planned")}</th>
|
||||
<th className="py-2 pr-3 text-right">{t("material.actual")}</th>
|
||||
<th className="py-2 pr-3 text-right">{t("material.deviation")}</th>
|
||||
<th className="py-2 pr-3">{t("material.reason")}</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, idx) => (
|
||||
<tr key={r.planId ?? `u${idx}`} className="border-b last:border-0 align-top">
|
||||
<td className="py-2 pr-3">
|
||||
<p className="font-semibold">{r.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[r.articleNumber, r.planId === null ? t("material.additional") : null, ...r.statuses.map((s) => t(`material.usage.${s}` as "material.usage.fully_used"))].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{r.notes && <p className="text-xs text-muted-foreground">{r.notes}</p>}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right whitespace-nowrap">{r.planned === null ? "—" : `${nf.format(r.planned)} ${r.unit}`}</td>
|
||||
<td className="py-2 pr-3 text-right whitespace-nowrap">{r.actual === null ? <span className="text-muted-foreground">{t("material.notRecorded")}</span> : `${nf.format(r.actual)} ${r.unit}`}</td>
|
||||
<td className={`py-2 pr-3 text-right font-semibold whitespace-nowrap ${r.deviation ? "text-[var(--warn)]" : ""}`}>
|
||||
{r.deviation === null ? "—" : `${r.deviation > 0 ? "+" : ""}${nf.format(r.deviation)} ${r.unit}`}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs">{r.reasons.join("; ") || "—"}</td>
|
||||
<td className="py-2 text-right">
|
||||
{canEdit && r.planId && r.actual === null && (
|
||||
<ActionForm action={removeMaterialPlanAction} submitLabel={t("material.remove")} variant="ghost" footerClassName="mt-0 justify-end">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<Hidden name="planId" value={r.planId} />
|
||||
</ActionForm>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<ActionForm action={addMaterialPlanAction} submitLabel={t("material.add")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Field label={`${t("material.name")} *`} htmlFor="m-name" className="lg:col-span-2">
|
||||
<input id="m-name" name="name" required maxLength={200} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("material.articleNumber")} htmlFor="m-art">
|
||||
<input id="m-art" name="articleNumber" maxLength={80} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={`${t("material.planned")} *`} htmlFor="m-qty">
|
||||
<input id="m-qty" name="plannedQuantity" required inputMode="decimal" pattern="[0-9]+([.,][0-9]{1,3})?" className={inputCls} />
|
||||
</Field>
|
||||
<Field label={`${t("material.unit")} *`} htmlFor="m-unit">
|
||||
<input id="m-unit" name="unit" required maxLength={20} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("material.notes")} htmlFor="m-notes" className="sm:col-span-2 lg:col-span-5">
|
||||
<input id="m-notes" name="notes" maxLength={1000} className={inputCls} />
|
||||
</Field>
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const sessions = await getTimesTab(ctx, wo.id);
|
||||
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((s) => (
|
||||
<Section key={s.id} title={`${s.user.name} · ${t(`times.sessionStatus.${s.status}`)}`} actions={<span className="text-sm font-semibold">{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}</span>}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-sm">
|
||||
<thead className="border-b text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-1.5 pr-3">{t("columns.status")}</th>
|
||||
<th className="py-1.5 pr-3">{t("times.start")}</th>
|
||||
<th className="py-1.5 pr-3">{t("times.end")}</th>
|
||||
<th className="py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.entries.map((e) => (
|
||||
<tr key={e.id} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td>
|
||||
<td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td>
|
||||
<td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td>
|
||||
<td className="py-1.5 text-xs text-[var(--warn)]">{e.corrected ? t("times.corrected", { reason: e.correctionReason ?? "" }) : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function PhotosTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const photos = await getPhotosTab(ctx, wo.id);
|
||||
if (photos.length === 0) return <Empty>{t("photos.empty")}</Empty>;
|
||||
return (
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{photos.map((p) => (
|
||||
<li key={p.id} className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<a href={`/files/${p.document.storageKey}`} className="block aspect-[4/3] bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- tenant file route, no next/image optimisation for private files */}
|
||||
<img src={`/files/${p.document.previewKey ?? p.document.storageKey}`} alt={p.comment ?? p.photoRequirement?.label ?? p.document.fileName} className="size-full object-cover" loading="lazy" />
|
||||
</a>
|
||||
<div className="p-2 text-xs">
|
||||
<p className="flex items-center gap-1 font-semibold">
|
||||
<Camera className="size-3.5" aria-hidden />
|
||||
{p.photoRequirement?.label ?? p.checklistItem?.label ?? (p.phase ? t(`photos.phase.${p.phase}`) : p.document.fileName)}
|
||||
</p>
|
||||
{p.comment && <p className="text-muted-foreground">{p.comment}</p>}
|
||||
<p className="text-muted-foreground">{formatDateTime(p.takenAt, locale, tz)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export async function NotesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const notes = await getNotesTab(ctx, wo.id);
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{ctx.permissions.has("work_order:read_all") && (
|
||||
<Section title={t("notes.internal")}>
|
||||
<p className="text-sm whitespace-pre-wrap">{wo.internalNotes || "—"}</p>
|
||||
</Section>
|
||||
)}
|
||||
<Section title={t("notes.technician")}>
|
||||
<p className="text-sm whitespace-pre-wrap">{wo.technicianNotes || "—"}</p>
|
||||
</Section>
|
||||
<Section title={t("detail.tabs.notes")} className="lg:col-span-2">
|
||||
{notes.length === 0 ? (
|
||||
<Empty>{t("notes.empty")}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{notes.map((n) => (
|
||||
<li key={n.id} className="py-2.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {n.authorName ?? t("history.system")} · {formatDateTime(n.createdAt, locale, tz)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm whitespace-pre-wrap">{n.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ReportsTab({ ctx, wo, locale, tz, billing }: TabProps & { billing?: React.ReactNode }) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const reports = await getReportsTab(ctx, wo.id);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{billing && (
|
||||
<Section title={t("reports.billingTitle")}>
|
||||
<p className="mb-2 text-sm text-muted-foreground">{t("reports.billingHint")}</p>
|
||||
{billing}
|
||||
</Section>
|
||||
)}
|
||||
<Section title={t("detail.tabs.reports")}>
|
||||
{reports.length === 0 ? (
|
||||
<Empty>{t("reports.empty")}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{reports.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
{t(`reports.type.${r.type}`)} · {formatDate(r.reportDate, locale, tz)} · v{r.version}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`reports.status.${r.status}`)}
|
||||
{r.signature && ` · ${t("reports.signature", { outcome: t(`reports.signatureOutcome.${r.signature.outcome}`) })}`}
|
||||
{r.rejectionReason && ` · ${r.rejectionReason}`}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/reports/${r.id}`} className={buttonCls("outline")}>
|
||||
{t("reports.open")}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function DocumentsTab({ ctx, wo, locale, tz, uploadError, uploaded }: TabProps & { uploadError?: string; uploaded?: boolean }) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const docs = await getDocumentsTab(ctx, wo.id);
|
||||
const canUpload = ctx.permissions.has("document:write");
|
||||
const visibilities = allowedDocumentVisibility(ctx);
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||
<Section title={t("detail.tabs.documents")}>
|
||||
{docs.length === 0 ? (
|
||||
<Empty>{t("documents.empty")}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{docs.map((d) => (
|
||||
<li key={d.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{d.title || d.fileName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`documents.categories.${d.category}`)} · {t(`documents.visibilities.${d.visibility}`)} · v{d.version} · {formatDateTime(d.createdAt, locale, tz)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<a href={`/files/${d.storageKey}`} className={buttonCls("outline")}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.download")}
|
||||
</a>
|
||||
{canUpload && (
|
||||
<ActionForm action={archiveDocumentAction} submitLabel={t("documents.archive")} variant="ghost" footerClassName="mt-0">
|
||||
<Hidden name="workOrderId" value={wo.id} />
|
||||
<Hidden name="documentId" value={d.id} />
|
||||
</ActionForm>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
{canUpload && (
|
||||
<Section title={t("documents.upload")}>
|
||||
{uploadError && (
|
||||
<p role="alert" className="mb-3 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
|
||||
{t.has(`errors.${uploadError}`) ? t(`errors.${uploadError}` as "errors.internal") : t("errors.internal")}
|
||||
</p>
|
||||
)}
|
||||
{uploaded && <p role="status" className="mb-3 text-sm text-[var(--ok)]">✓ {t("saved")}</p>}
|
||||
<form method="post" encType="multipart/form-data" action={`/api/v1/work-orders/${wo.id}/documents`} className="space-y-3">
|
||||
<Field label={`${t("documents.file")} *`} htmlFor="doc-file">
|
||||
<input id="doc-file" type="file" name="file" required accept="application/pdf,image/jpeg,image/png,image/webp" className={`${inputCls} py-2`} />
|
||||
</Field>
|
||||
<Field label={t("documents.title")} htmlFor="doc-title">
|
||||
<input id="doc-title" name="title" maxLength={200} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("documents.category")} htmlFor="doc-cat">
|
||||
<select id="doc-cat" name="category" defaultValue="technical_drawing" className={inputCls}>
|
||||
{UPLOAD_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`documents.categories.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("documents.visibility")} htmlFor="doc-vis">
|
||||
<select id="doc-vis" name="visibility" defaultValue="team" className={inputCls}>
|
||||
{visibilities.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`documents.visibilities.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<button type="submit" className={buttonCls("default")}>
|
||||
{t("documents.submit")}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">{t("documents.stubHint")}</p>
|
||||
</form>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function HistoryTab({ ctx, wo, locale, tz }: TabProps) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const { changes, audit } = await getHistoryTab(ctx, wo.id);
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Section title={t("history.statusChanges")}>
|
||||
{changes.length === 0 ? (
|
||||
<Empty>{t("history.empty")}</Empty>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{changes.map((c) => (
|
||||
<li key={c.id} className="border-l-2 pl-3 text-sm">
|
||||
<p className="font-semibold">
|
||||
{c.fromStatus ? t("history.change", { from: t(`status.${c.fromStatus}`), to: t(`status.${c.toStatus}`) }) : t("history.created", { status: t(`status.${c.toStatus}`) })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(c.createdAt, locale, tz)} · {t("history.by", { name: c.actorName ?? t("history.system") })}
|
||||
</p>
|
||||
{c.reason && <p className="text-xs">{c.reason}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Section>
|
||||
{audit.length > 0 && (
|
||||
<Section title={t("history.audit")}>
|
||||
<ol className="space-y-2">
|
||||
{audit.map((a) => {
|
||||
const after = (a.after ?? {}) as Record<string, unknown>;
|
||||
const summary = typeof after.op === "string" ? after.op : Object.keys(after).filter((k) => k !== "version").join(", ");
|
||||
return (
|
||||
<li key={a.id} className="border-l-2 pl-3 text-sm">
|
||||
<p className="font-semibold">
|
||||
{t.has(`history.action.${a.action}`) ? t(`history.action.${a.action}` as "history.action.update") : a.action}
|
||||
{summary && <span className="font-normal text-muted-foreground"> · {summary}</span>}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTime(a.createdAt, locale, tz)} · {t("history.by", { name: a.actorName ?? t("history.system") })}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/**
|
||||
* Read-path context for server-rendered pages of the work order module. Permissions come from
|
||||
* the session (read paths; AGENTS.md) — every mutation re-checks DB-authoritatively via moduleGuard.
|
||||
*/
|
||||
export async function pageContext() {
|
||||
const session = await requireSession();
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions ?? []),
|
||||
};
|
||||
const [locale, tz] = await Promise.all([getLocale(), tenantTimezone(ctx)]);
|
||||
return { session, ctx, locale, tz, can: (p: string) => ctx.permissions.has(p) };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { LinkTabs } from "@/components/work-orders/ui";
|
||||
|
||||
/** Access check for the template settings pages (`settings:templates`). */
|
||||
export async function templatesPageContext() {
|
||||
const pc = await pageContext();
|
||||
if (!pc.can("settings:templates")) redirect("/dashboard");
|
||||
return pc;
|
||||
}
|
||||
|
||||
export async function SettingsTemplatesNav({ active }: { active: "order-types" | "checklists" | "numbering" }) {
|
||||
const t = await getTranslations("settingsTemplates");
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<LinkTabs
|
||||
label={t("crumb")}
|
||||
items={[
|
||||
{ href: "/settings/order-types", label: t("nav.orderTypes"), active: active === "order-types" },
|
||||
{ href: "/settings/checklists", label: t("nav.checklists"), active: active === "checklists" },
|
||||
{ href: "/settings/numbering", label: t("nav.numbering"), active: active === "numbering" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
CheckCheck,
|
||||
CircleDashed,
|
||||
ClipboardCheck,
|
||||
Receipt,
|
||||
TriangleAlert,
|
||||
Truck,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { STATUS_GROUP, STATUS_GROUP_TONE, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Presentational building blocks of the work order module (server-safe, no hooks).
|
||||
* Colours only via CSS tokens; status is always text + icon, never colour alone.
|
||||
*/
|
||||
|
||||
export const TONE_VAR: Record<(typeof STATUS_GROUP_TONE)[StatusGroup], string> = {
|
||||
neutral: "var(--txt-muted)",
|
||||
info: "var(--info)",
|
||||
accent: "var(--ui-accent)",
|
||||
warning: "var(--warn)",
|
||||
success: "var(--ok)",
|
||||
danger: "var(--risk)",
|
||||
};
|
||||
|
||||
const GROUP_ICON: Record<StatusGroup, LucideIcon> = {
|
||||
new: CircleDashed,
|
||||
planned: CalendarClock,
|
||||
en_route: Truck,
|
||||
in_progress: Wrench,
|
||||
documentation_incomplete: TriangleAlert,
|
||||
in_review: ClipboardCheck,
|
||||
ready_for_billing: Receipt,
|
||||
billed: CheckCheck,
|
||||
cancelled: Ban,
|
||||
};
|
||||
|
||||
export function statusColor(status: WorkOrderStatus): string {
|
||||
return TONE_VAR[STATUS_GROUP_TONE[STATUS_GROUP[status]]];
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label, className }: { status: WorkOrderStatus; label: string; className?: string }) {
|
||||
const Icon = GROUP_ICON[STATUS_GROUP[status]];
|
||||
const color = statusColor(status);
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap", className)}
|
||||
style={{ color, borderColor: color, background: `color-mix(in oklch, ${color} 10%, transparent)` }}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupIcon({ group, className }: { group: StatusGroup; className?: string }) {
|
||||
const Icon = GROUP_ICON[group];
|
||||
return <Icon className={className} aria-hidden />;
|
||||
}
|
||||
|
||||
export const inputCls =
|
||||
"min-h-11 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
export function Field({ label, htmlFor, hint, className, children }: { label: string; htmlFor?: string; hint?: string; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label htmlFor={htmlFor} className="mb-1 block text-[13px] font-semibold">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Check({ name, label, defaultChecked, value, id }: { name: string; label: string; defaultChecked?: boolean; value?: string; id?: string }) {
|
||||
return (
|
||||
<label htmlFor={id} className="flex min-h-11 cursor-pointer items-center gap-2.5 text-sm">
|
||||
<input id={id} type="checkbox" name={name} value={value} defaultChecked={defaultChecked} className="size-5 accent-[var(--ui-primary)]" />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export type TabItem = { href: string; label: string; active: boolean; count?: number; icon?: React.ReactNode };
|
||||
|
||||
/** Link-based tabs (server rendering, deep-linkable, keyboard accessible). */
|
||||
export function LinkTabs({ items, label }: { items: TabItem[]; label: string }) {
|
||||
return (
|
||||
<nav aria-label={label} className="-mx-1 overflow-x-auto">
|
||||
<ul className="flex min-w-max gap-1 border-b px-1">
|
||||
{items.map((i) => (
|
||||
<li key={i.href}>
|
||||
<Link
|
||||
href={i.href}
|
||||
scroll={false}
|
||||
aria-current={i.active ? "page" : undefined}
|
||||
className={cn(
|
||||
"-mb-px inline-flex min-h-11 items-center gap-1.5 border-b-2 px-3 text-[13px] font-semibold whitespace-nowrap transition-colors",
|
||||
i.active ? "border-[var(--ui-accent)] text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{i.icon}
|
||||
{i.label}
|
||||
{i.count !== undefined && <span className="rounded-full bg-muted px-1.5 text-[11px] text-muted-foreground">{i.count}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({ title, actions, children, className }: { title?: string; actions?: React.ReactNode; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={cn("shadow-card rounded-xl border bg-card p-4 md:p-5", className)}>
|
||||
{(title || actions) && (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
{title && <h2 className="font-heading text-[15px] font-semibold">{title}</h2>}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ children }: { children: React.ReactNode }) {
|
||||
return <p className="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">{children}</p>;
|
||||
}
|
||||
|
||||
export function Dl({ rows }: { rows: [string, React.ReactNode][] }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-[minmax(110px,auto)_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted-foreground">{k}</dt>
|
||||
<dd className="min-w-0 break-words">{v || "—"}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { BILLING_TYPES, WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||
import { toWallTimeInput } from "@/lib/work-orders/time";
|
||||
import { Field, inputCls } from "@/components/work-orders/ui";
|
||||
|
||||
type Option = { id: string; name: string };
|
||||
|
||||
export type WorkOrderDefaults = {
|
||||
title?: string;
|
||||
siteId?: string | null;
|
||||
contactId?: string | null;
|
||||
orderTypeId?: string | null;
|
||||
priority?: string;
|
||||
plannedStart?: Date | null;
|
||||
plannedEnd?: Date | null;
|
||||
description?: string | null;
|
||||
scope?: string | null;
|
||||
internalNotes?: string | null;
|
||||
technicianNotes?: string | null;
|
||||
signatureRequired?: boolean;
|
||||
billingType?: string | null;
|
||||
externalOrderNumber?: string | null;
|
||||
offerNumber?: string | null;
|
||||
};
|
||||
|
||||
/** Shared fields of the create and edit popups (server-rendered, posted to server actions). */
|
||||
export async function WorkOrderFields({
|
||||
mode,
|
||||
sites,
|
||||
contacts,
|
||||
orderTypes,
|
||||
tz,
|
||||
defaults = {},
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
sites: Option[];
|
||||
contacts: Option[];
|
||||
orderTypes: Option[];
|
||||
tz: string;
|
||||
defaults?: WorkOrderDefaults;
|
||||
}) {
|
||||
const t = await getTranslations("workOrders");
|
||||
const d = defaults;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={`${t("fields.title")} *`} htmlFor="wo-title" className="md:col-span-2">
|
||||
<input id="wo-title" name="title" required maxLength={200} defaultValue={d.title ?? ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("fields.site")} htmlFor="wo-site">
|
||||
<select id="wo-site" name="siteId" defaultValue={d.siteId ?? ""} className={inputCls}>
|
||||
<option value="">{t("fields.none")}</option>
|
||||
{sites.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("fields.contact")} htmlFor="wo-contact">
|
||||
<select id="wo-contact" name="contactId" defaultValue={d.contactId ?? ""} className={inputCls}>
|
||||
<option value="">{t("fields.none")}</option>
|
||||
{contacts.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("fields.orderType")} htmlFor="wo-type">
|
||||
<select id="wo-type" name="orderTypeId" defaultValue={d.orderTypeId ?? ""} className={inputCls}>
|
||||
<option value="">{t("fields.none")}</option>
|
||||
{orderTypes.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("fields.priority")} htmlFor="wo-priority">
|
||||
<select id="wo-priority" name="priority" defaultValue={d.priority ?? "normal"} className={inputCls}>
|
||||
{WORK_ORDER_PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`priority.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("fields.plannedStart")} htmlFor="wo-start" hint={t("fields.timezoneHint", { tz })}>
|
||||
<input id="wo-start" type="datetime-local" name="plannedStart" defaultValue={toWallTimeInput(d.plannedStart, tz)} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("fields.plannedEnd")} htmlFor="wo-end">
|
||||
<input id="wo-end" type="datetime-local" name="plannedEnd" defaultValue={toWallTimeInput(d.plannedEnd, tz)} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("fields.description")} htmlFor="wo-desc" className="md:col-span-2">
|
||||
<textarea id="wo-desc" name="description" rows={3} maxLength={10000} defaultValue={d.description ?? ""} className={`${inputCls} py-2`} />
|
||||
</Field>
|
||||
<Field label={t("fields.scope")} htmlFor="wo-scope" className="md:col-span-2">
|
||||
<textarea id="wo-scope" name="scope" rows={3} maxLength={10000} defaultValue={d.scope ?? ""} className={`${inputCls} py-2`} />
|
||||
</Field>
|
||||
<Field label={t("fields.technicianNotes")} htmlFor="wo-tech">
|
||||
<textarea id="wo-tech" name="technicianNotes" rows={3} maxLength={5000} defaultValue={d.technicianNotes ?? ""} className={`${inputCls} py-2`} />
|
||||
</Field>
|
||||
<Field label={t("fields.internalNotes")} htmlFor="wo-internal">
|
||||
<textarea id="wo-internal" name="internalNotes" rows={3} maxLength={5000} defaultValue={d.internalNotes ?? ""} className={`${inputCls} py-2`} />
|
||||
</Field>
|
||||
<Field label={t("fields.signatureRequired")} htmlFor="wo-sig">
|
||||
<select
|
||||
id="wo-sig"
|
||||
name="signatureRequired"
|
||||
defaultValue={mode === "create" ? "" : d.signatureRequired === false ? "no" : "yes"}
|
||||
className={inputCls}
|
||||
>
|
||||
{mode === "create" && <option value="">{t("fields.signatureFromType")}</option>}
|
||||
<option value="yes">{t("fields.yes")}</option>
|
||||
<option value="no">{t("fields.no")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("fields.billingType")} htmlFor="wo-billing">
|
||||
<select id="wo-billing" name="billingType" defaultValue={d.billingType ?? ""} className={inputCls}>
|
||||
<option value="">{t("fields.none")}</option>
|
||||
{BILLING_TYPES.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{t(`billingType.${b}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("fields.externalOrderNumber")} htmlFor="wo-ext">
|
||||
<input id="wo-ext" name="externalOrderNumber" maxLength={80} defaultValue={d.externalOrderNumber ?? ""} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("fields.offerNumber")} htmlFor="wo-offer">
|
||||
<input id="wo-offer" name="offerNumber" maxLength={80} defaultValue={d.offerNumber ?? ""} className={inputCls} />
|
||||
</Field>
|
||||
{mode === "create" && (
|
||||
<Field label={t("fields.status")} htmlFor="wo-status">
|
||||
<select id="wo-status" name="status" defaultValue="draft" className={inputCls}>
|
||||
<option value="draft">{t("status.draft")}</option>
|
||||
<option value="planned">{t("status.planned")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { MapPin, Siren, UsersRound } from "lucide-react";
|
||||
import type { WorkOrderListItem } from "@/server/services/work-orders/list";
|
||||
import { customerDisplayName } from "@/server/services/work-orders/options";
|
||||
import { formatDateTime } from "@/lib/work-orders/time";
|
||||
import { StatusBadge, statusColor } from "@/components/work-orders/ui";
|
||||
|
||||
function planned(i: WorkOrderListItem, locale: string, tz: string, noDate: string) {
|
||||
if (!i.plannedStart) return noDate;
|
||||
const start = formatDateTime(i.plannedStart, locale, tz);
|
||||
return i.plannedEnd ? `${start} – ${formatDateTime(i.plannedEnd, locale, tz)}` : start;
|
||||
}
|
||||
|
||||
function siteLine(i: WorkOrderListItem) {
|
||||
if (!i.site) return "";
|
||||
const addr = [[i.site.street, i.site.houseNumber].filter(Boolean).join(" "), i.site.city].filter(Boolean).join(", ");
|
||||
return addr ? `${i.site.name} · ${addr}` : i.site.name;
|
||||
}
|
||||
|
||||
/** Cards with status edge (Brandbook §12.2). */
|
||||
export async function WorkOrderCards({ items, locale, tz }: { items: WorkOrderListItem[]; locale: string; tz: string }) {
|
||||
const t = await getTranslations("workOrders");
|
||||
return (
|
||||
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((i) => (
|
||||
<li key={i.id}>
|
||||
<Link
|
||||
href={`/work-orders/${i.id}`}
|
||||
className="shadow-card block h-full 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: statusColor(i.status) }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">{i.number}</span>
|
||||
<StatusBadge status={i.status} label={t(`status.${i.status}`)} />
|
||||
</div>
|
||||
<p className="mt-1.5 font-heading text-[15px] font-semibold text-foreground">{i.title}</p>
|
||||
<p className="mt-0.5 text-[13px]">{customerDisplayName(i.customer)}</p>
|
||||
{i.site && (
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="size-3.5 shrink-0" aria-hidden />
|
||||
{siteLine(i)}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{planned(i, locale, tz, t("noDate"))}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<UsersRound className="size-3.5" aria-hidden />
|
||||
{i.team?.name ?? t("unassigned")}
|
||||
</span>
|
||||
{i.priority !== "normal" && <span className="font-semibold text-foreground">{t(`priority.${i.priority}`)}</span>}
|
||||
{i.isEmergency && (
|
||||
<span className="flex items-center gap-1 font-semibold text-[var(--risk)]">
|
||||
<Siren className="size-3.5" aria-hidden />
|
||||
{t("emergency")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export async function WorkOrderTable({ items, locale, tz }: { items: WorkOrderListItem[]; locale: string; tz: string }) {
|
||||
const t = await getTranslations("workOrders");
|
||||
return (
|
||||
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<thead className="border-b bg-muted/40 text-left text-xs text-muted-foreground">
|
||||
<tr>
|
||||
{(["number", "title", "customer", "site", "planned", "team", "priority", "status"] as const).map((c) => (
|
||||
<th key={c} scope="col" className="px-3 py-2.5 font-semibold">
|
||||
{t(`columns.${c}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((i) => (
|
||||
<tr key={i.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="border-l-4 px-3 py-2.5 font-mono text-xs" style={{ borderLeftColor: statusColor(i.status) }}>
|
||||
<Link href={`/work-orders/${i.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)] hover:underline">
|
||||
{i.number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{i.title}
|
||||
{i.isEmergency && <span className="ml-2 text-xs font-semibold text-[var(--risk)]">{t("emergency")}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">{customerDisplayName(i.customer)}</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">{siteLine(i)}</td>
|
||||
<td className="px-3 py-2.5 text-xs">{planned(i, locale, tz, t("noDate"))}</td>
|
||||
<td className="px-3 py-2.5">{i.team?.name ?? t("unassigned")}</td>
|
||||
<td className="px-3 py-2.5">{t(`priority.${i.priority}`)}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<StatusBadge status={i.status} label={t(`status.${i.status}`)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user