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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user