Merge lane/planung in feature/craftvia-mvp

Konflikte gelöst (additiv): Benachrichtigungstexte (Zeiterfassung + Planung, JSON zusammengeführt),
Event-Typen, Navigation, Empfänger-Felder, handle-event (reason inkl. rejectionReason + Planungsfelder),
Smoke-Prüfungen (Teamleiter: Zeiten + Planung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:15:47 +02:00
co-authored by Claude Opus 5
77 changed files with 6326 additions and 21 deletions
+2
View File
@@ -17,6 +17,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { PlanningTiles } from "@/components/planning/conflicts-tile";
import { buttonCls } from "@/components/work-orders/button-cls";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls } from "@/components/work-orders/ui";
@@ -164,6 +165,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
</li>
);
})}
<PlanningTiles ctx={ctx} />
</ul>
</>
)}
+2 -1
View File
@@ -67,7 +67,8 @@ export default async function AppLayout({
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
{mainNav.map((item) => (
<NavLink key={item.href} href={item.href}>
<NavLink key={`${item.href}:${item.label}`} href={item.href} exact={item.exact}>
{item.sub && <span className="w-4 shrink-0" aria-hidden />}
<item.icon className="size-[18px] opacity-85" />
{t(item.label)}
</NavLink>
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("work_orders");
return <>{children}</>;
}
+34
View File
@@ -0,0 +1,34 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ShieldCheck } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { LiveSituationView } from "@/components/planning/live-situation";
import { pageContext } from "@/components/work-orders/page-context";
import { ServiceError } from "@/server/services/context";
import { mapConfig } from "@/server/services/geo/config";
import { getLiveSituation } from "@/server/services/planning/live";
/** Live-Lage (L13): technicians at the site of their running order — no GPS tracking. */
export default async function PlanningLivePage() {
const { ctx, locale } = await pageContext();
let initial;
try {
initial = await getLiveSituation(ctx);
} catch (err) {
if (err instanceof ServiceError && err.code === "forbidden") notFound();
throw err;
}
const t = await getTranslations("planning");
const map = mapConfig();
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("live.title")} sub={t("live.sub")} />
<p className="mb-3 flex items-center gap-2 rounded-lg border border-[var(--info)] bg-card px-3 py-2 text-sm">
<ShieldCheck className="size-4 shrink-0 text-[var(--info)]" aria-hidden />
{t("live.privacy")}
</p>
<LiveSituationView initial={initial} tileUrl={map.tileUrl} attribution={map.attribution} locale={locale} />
</main>
);
}
+295
View File
@@ -0,0 +1,295 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ChevronLeft, ChevronRight, Info, MonitorSmartphone, TriangleAlert } from "lucide-react";
import { Modal } from "@/components/modal";
import { PageHead } from "@/components/mockup-ui";
import { CapacityForm } from "@/components/planning/capacity-form";
import { PlanningBoardView, type TeamLive } from "@/components/planning/planning-board";
import { buttonCls } from "@/components/work-orders/button-cls";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls } from "@/components/work-orders/ui";
import { addDays, addWorkingDays, dayKeyOf, dayRange, isDayKey, nextWorkingDays, startOfWeek, wallClock } from "@/lib/planning/days";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { cn } from "@/lib/utils";
import { saveTeamCapacityAction } from "@/server/actions/work_orders/planning";
import { ServiceError } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
import { getPlanningBoard } from "@/server/services/planning/board";
import { loadOrder, loadTeams, toBoardOrder } from "@/server/services/planning/data";
import { getLiveSituation } from "@/server/services/planning/live";
import { listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record<string, string | string[] | undefined>;
type View = "today" | "days" | "week";
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
/**
* Planung (L13): default "Heute + nächste 4 Werktage" (teams as rows, today highlighted with the live
* status of each crew); switcher Heute (day view, teams as parallel columns) · 5 Tage · Woche · Nächste Woche.
*/
export default async function PlanningPage({ searchParams }: { searchParams: Promise<SP> }) {
const { ctx, locale, tz } = await pageContext();
const sp = await searchParams;
let access;
try {
access = await planningAccess(ctx);
} catch (err) {
if (err instanceof ServiceError) notFound();
throw err;
}
const [t, tw, tc] = await Promise.all([getTranslations("planning"), getTranslations("workOrders"), getTranslations("common")]);
const today = dayKeyOf(new Date(), tz);
const viewParam = one(sp.view);
const view: View = viewParam === "today" || viewParam === "day" ? "today" : viewParam === "week" ? "week" : "days";
const date = isDayKey(one(sp.date)) ? (one(sp.date) as string) : today;
const visibleDays = view === "today" ? [date] : view === "week" ? dayRange(startOfWeek(date), addDays(startOfWeek(date), 6)) : nextWorkingDays(date, 5);
const from = visibleDays[0];
const to = visibleDays[visibleDays.length - 1];
const teamId = one(sp.teamId) || undefined;
const orderTypeId = one(sp.orderTypeId) || undefined;
const priorityParam = one(sp.priority);
const priority = (WORK_ORDER_PRIORITIES as readonly string[]).includes(priorityParam ?? "") ? (priorityParam as (typeof WORK_ORDER_PRIORITIES)[number]) : undefined;
const includesToday = visibleDays.includes(today);
const [board, teams, orderTypes, live] = await Promise.all([
getPlanningBoard(ctx, { from, to, teamIds: teamId ? [teamId] : undefined, orderTypeId, priority }),
loadTeams(ctx, access.teamIds),
listOrderTypes(ctx, { activeOnly: true }),
includesToday ? getLiveSituation(ctx, { teamId }).catch(() => null) : Promise.resolve(null),
]);
const teamLive: Record<string, TeamLive> | null = live
? Object.fromEntries(
live.crews.map((c) => [c.teamId, { status: c.status, delay: c.delay, freedMinutes: c.freedMinutes, active: c.members.filter((m) => m.status !== "free").length }]),
)
: null;
const query = (overrides: Record<string, string | undefined>) => {
const qs = new URLSearchParams();
const base: Record<string, string | undefined> = { view: view === "days" ? undefined : view, date: date === today ? undefined : date, teamId, orderTypeId, priority, ...overrides };
for (const [k, v] of Object.entries(base)) if (v) qs.set(k, v);
const s = qs.toString();
return `/planning${s ? `?${s}` : ""}`;
};
const selfHref = query({});
const prevDate = view === "today" ? addDays(date, -1) : view === "week" ? addDays(date, -7) : addWorkingDays(date, -5);
const nextDate = view === "today" ? addDays(date, 1) : view === "week" ? addDays(date, 7) : addWorkingDays(to, 1);
const thisWeek = startOfWeek(today);
const nextWeek = addDays(thisWeek, 7);
const switcher: { key: "today" | "days" | "week" | "nextWeek"; href: string; active: boolean }[] = [
{ key: "today", href: query({ view: "today", date: undefined }), active: view === "today" && date === today },
{ key: "days", href: query({ view: undefined, date: undefined }), active: view === "days" && date === today },
{ key: "week", href: query({ view: "week", date: undefined }), active: view === "week" && startOfWeek(date) === thisWeek },
{ key: "nextWeek", href: query({ view: "week", date: nextWeek }), active: view === "week" && startOfWeek(date) === nextWeek },
];
const navLabels = view === "today" ? [t("board.prevDay"), t("board.nextDay")] : view === "week" ? [t("board.prevWeek"), t("board.nextWeek")] : [t("board.prevDays"), t("board.nextDays")];
// Prefill from the work order detail ("Übernehmen"), live situation ("Vorziehen") or deep links.
let prefill = null;
const scheduleId = one(sp.schedule);
if (scheduleId && access.canSchedule) {
const row = await loadOrder(ctx, scheduleId);
const prefillTeam = one(sp.scheduleTeam);
if (row) {
prefill = {
order: board.orders[row.id] ?? board.unplanned.find((o) => o.id === row.id) ?? toBoardOrder(row, tz),
teamId: prefillTeam && board.teams.some((tm) => tm.id === prefillTeam) ? prefillTeam : null,
day: isDayKey(one(sp.day)) ? (one(sp.day) as string) : null,
time: TIME_RE.test(one(sp.time) ?? "") ? (one(sp.time) as string) : null,
};
}
}
const capacityTeam = access.canManageTeams ? teams.find((tm) => tm.id === one(sp.capacity)) : undefined;
const listDays = board.days.filter((d) => visibleDays.includes(d.key));
return (
<main className="flex-1 p-4 md:p-6">
<PageHead
crumb={t("crumb")}
title={t("board.title")}
sub={t("board.sub")}
actions={
<div className="flex flex-wrap items-center gap-2">
<nav aria-label={t("board.viewLabel")} className="flex flex-wrap rounded-lg border p-0.5">
{switcher.map((s) => (
<Link
key={s.key}
href={s.href}
aria-current={s.active ? "page" : undefined}
className={cn("inline-flex min-h-10 items-center rounded-md px-3 text-sm font-semibold", s.active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}
>
{t(`board.view.${s.key}`)}
</Link>
))}
</nav>
<nav aria-label={t("board.navLabel")} className="flex items-center gap-1">
<Link href={query({ date: prevDate })} aria-label={navLabels[0]} title={navLabels[0]} className={buttonCls("outline")}>
<ChevronLeft className="size-4" aria-hidden />
</Link>
<Link href={query({ date: undefined })} className={buttonCls("outline")}>
{t("board.today")}
</Link>
<Link href={query({ date: nextDate })} aria-label={navLabels[1]} title={navLabels[1]} className={buttonCls("outline")}>
<ChevronRight className="size-4" aria-hidden />
</Link>
</nav>
</div>
}
/>
<p className="mb-3 text-sm font-semibold">
{view === "today"
? date === today
? t("board.todayLabel", { day: formatDayShort(date, locale) })
: formatDayShort(date, locale)
: t("board.range", { from: formatDayShort(from, locale), to: formatDayShort(to, locale) })}
</p>
{!access.all && (
<p className="mb-3 flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Info className="size-4" aria-hidden />
{t("board.readOnly")}
</p>
)}
<details className="shadow-card mb-3 rounded-xl border bg-card" open={Boolean(teamId || orderTypeId || priority)}>
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filters.title")}</summary>
<form method="get" action="/planning" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
{view !== "days" && <input type="hidden" name="view" value={view} />}
{date !== today && <input type="hidden" name="date" value={date} />}
<Field label={t("filters.team")} htmlFor="pf-team">
<select id="pf-team" name="teamId" defaultValue={teamId ?? ""} className={inputCls}>
<option value="">{t("filters.any")}</option>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</Field>
<Field label={t("filters.orderType")} htmlFor="pf-type">
<select id="pf-type" name="orderTypeId" defaultValue={orderTypeId ?? ""} className={inputCls}>
<option value="">{t("filters.any")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={t("filters.priority")} htmlFor="pf-prio">
<select id="pf-prio" name="priority" defaultValue={priority ?? ""} className={inputCls}>
<option value="">{t("filters.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")}>
{t("filters.apply")}
</button>
<Link href={query({ teamId: undefined, orderTypeId: undefined, priority: undefined })} className={buttonCls("ghost")}>
{t("filters.reset")}
</Link>
</div>
</form>
</details>
<p className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<TriangleAlert className="size-3.5" aria-hidden />
{t("conflicts.count", { count: board.conflictCount })}
{board.access.canSchedule && <span className="hidden lg:inline"> · {t("board.dragHint")}</span>}
</p>
<div className="hidden lg:block">
<PlanningBoardView board={board} view={view} visibleDays={visibleDays} locale={locale} prefill={prefill} capacityHref={selfHref} freed={live?.freed ?? null} teamLive={teamLive} />
</div>
{/* Below 1024 px: hint + simple list (no drag & drop). */}
<div className="space-y-3 lg:hidden">
<p className="flex items-start gap-2 rounded-lg border border-[var(--info)] bg-card px-3 py-2 text-sm">
<MonitorSmartphone className="mt-0.5 size-4 shrink-0 text-[var(--info)]" aria-hidden />
{t("board.narrowHint")}
</p>
{listDays.map((d) => (
<section key={d.key} className="shadow-card rounded-xl border bg-card p-3">
<h2 className="font-heading text-[15px] font-semibold">
{formatDayShort(d.key, locale)}
{d.key === today && ` · ${t("board.today")}`}
</h2>
<ul className="mt-1 space-y-2">
{board.teams.map((team) => {
const td = team.days.find((x) => x.day === d.key)!;
const liveTeam = d.key === today ? teamLive?.[team.id] : undefined;
return (
<li key={team.id} className="text-sm">
<p className="font-semibold">
{team.name} ·{" "}
<span className="font-normal text-muted-foreground">
{td.utilization === null
? t("board.loadNoCapacity", { planned: formatMinutes(td.plannedMinutes, locale) })
: t("board.load", { planned: formatMinutes(td.plannedMinutes, locale), capacity: formatMinutes(td.capacityMinutes, locale), percent: td.utilization })}
</span>
{liveTeam && <span className="font-normal"> · {t(`live.status.${liveTeam.status}`)}</span>}
</p>
{td.conflicts.length > 0 && (
<p className="flex items-center gap-1 text-xs font-semibold text-[var(--risk)]">
<TriangleAlert className="size-3.5" aria-hidden />
{td.conflicts.map((c) => t(`conflicts.short.${c.kind}`)).join(", ")}
</p>
)}
{td.orderIds.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("board.listDayEmpty")}</p>
) : (
<ul className="text-xs">
{td.orderIds.map((id) => {
const o = board.orders[id];
return (
<li key={id}>
<Link href={`/work-orders/${id}`} className="inline-flex min-h-11 items-center gap-1.5">
{!o.allDay && o.plannedStart && <span className="tabular-nums">{wallClock(new Date(o.plannedStart), tz)}</span>}
<span className="font-mono font-semibold">{o.number}</span>
<span>{o.customerName}</span>
{o.delay && <span className="font-semibold text-[var(--warn)]">{o.delay.percent} %</span>}
</Link>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</section>
))}
<section className="shadow-card rounded-xl border bg-card p-3">
<h2 className="font-heading text-[15px] font-semibold">
{t("unplanned.title")} ({board.unplanned.length})
</h2>
<ul className="mt-1 text-sm">
{board.unplanned.map((o) => (
<li key={o.id}>
<Link href={`/work-orders/${o.id}`} className="inline-flex min-h-11 items-center gap-1.5">
<span className="font-mono font-semibold">{o.number}</span> {o.customerName}
</Link>
</li>
))}
</ul>
</section>
</div>
{capacityTeam && (
<Modal title={t("capacity.title")} sub={`${capacityTeam.name} · ${t("capacity.sub")}`} closeHref={selfHref} closeLabel={tc("close")}>
<CapacityForm action={saveTeamCapacityAction.bind(null, capacityTeam.id, selfHref)} minutes={capacityTeam.dailyCapacityMinutes} workingDays={capacityTeam.workingDays} closeHref={selfHref} />
</Modal>
)}
</main>
);
}
+4
View File
@@ -3,6 +3,8 @@ 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 { PlanningSuggestions } from "@/components/planning/suggestions-section";
import { SCHEDULABLE_STATUSES } from "@/lib/planning/capacity";
import { ActionForm } from "@/components/work-orders/action-form";
import { buttonCls } from "@/components/work-orders/button-cls";
import {
@@ -172,6 +174,8 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
</div>
</header>
{tab === "overview" && canAssign && SCHEDULABLE_STATUSES.includes(wo.status) && <PlanningSuggestions ctx={ctx} workOrderId={wo.id} locale={locale} tz={tz} />}
<div className="mt-4">
<LinkTabs
label={t("detail.tabs.overview")}
+24
View File
@@ -0,0 +1,24 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getPlanningBoard, weekRange } from "@/server/services/planning/board";
/**
* GET /api/v1/planning/board?from=YYYY-MM-DD&to=YYYY-MM-DD&teamId=…&orderTypeId=…&priority=…
* Default range: current week (Mon–Sun, tenant timezone), max. 42 days. Backoffice: all teams;
* team leads: own teams (read-only). Access is checked in the service.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders");
const sp = new URL(req.url).searchParams;
const week = await weekRange(ctx, sp.get("from"));
const teamIds = sp.getAll("teamId").filter(Boolean);
return json(
await getPlanningBoard(ctx, {
from: sp.get("from") ?? week.from,
to: sp.get("to") ?? week.to,
teamIds: teamIds.length ? teamIds : undefined,
orderTypeId: sp.get("orderTypeId") || undefined,
priority: (sp.get("priority") || undefined) as "low" | "normal" | "high" | "urgent" | undefined,
}),
);
});
+19
View File
@@ -0,0 +1,19 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getLiveSituation, LIVE_STATUSES, type LiveStatus } from "@/server/services/planning/live";
/**
* GET /api/v1/planning/live?teamId=…&status=en_route|working|paused|free — polled every 30 s by
* /planning/live. Location = site of the running order; never device coordinates.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders");
const sp = new URL(req.url).searchParams;
const status = sp.get("status");
return json(
await getLiveSituation(ctx, {
teamId: sp.get("teamId") || undefined,
status: status && (LIVE_STATUSES as readonly string[]).includes(status) ? (status as LiveStatus) : undefined,
}),
);
});
@@ -0,0 +1,25 @@
import { requireApiContext } from "@/server/api/context";
import { ApiError, json, withApi } from "@/server/api/respond";
import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend";
/**
* GET /api/v1/planning/recommendations?workOrderId=…&days=10&radiusKm=25&nearbyRadiusKm=5&locale=de
* Suggestions only (straight-line distance + free capacity); nothing is saved.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders", "work_order:assign");
const sp = new URL(req.url).searchParams;
const workOrderId = sp.get("workOrderId");
if (!workOrderId) throw new ApiError("invalid", "workOrderId required");
const locale = sp.get("locale") === "en" ? "en" : "de";
const [recommendations, nearby] = await Promise.all([
recommendAssignments(ctx, {
workOrderId,
days: sp.get("days") ?? undefined,
radiusKm: sp.get("radiusKm") ?? undefined,
locale,
}),
findNearbyUnplanned(ctx, { workOrderId, radiusKm: sp.get("nearbyRadiusKm") ?? undefined }),
]);
return json({ ...recommendations, nearby });
});
+15
View File
@@ -0,0 +1,15 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { scheduleWorkOrder, type ScheduleInput } from "@/server/services/planning/schedule";
/**
* POST /api/v1/planning/schedule — body { workOrderId, teamId, plannedStart, plannedEnd?, plannedDurationMinutes?, baseVersion }.
* Assign + dates in one transaction (L2 services, event work_order.assigned, audit). 409 conflict when
* the order changed meanwhile ("Auftrag wurde zwischenzeitlich geändert"). Response contains the
* conflicts of the target team on the target day(s).
*/
export const POST = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders", "work_order:assign");
const body = await readJsonObject(req);
return json(await scheduleWorkOrder(ctx, body as ScheduleInput));
});