|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|