Data model - employee_assignments records org placement over time (valid_from/valid_to), written by a trigger on `employees` rather than inside each RPC: ~70 `update employees` statements spread over fifteen migrations mean per-call bookkeeping would miss paths today and again with every future RPC. A partial unique index enforces the one-open-interval invariant the trigger relies on when closing the current row. - The Organigramm gains a Stichtag (default today). Membership comes from entry/exit/karenz, past placement from the new history, future placement projected from pending_org_changes. Placements predating the migration are backfilled with today's values and flagged as such in the UI, since employee_history only ever stored free text and cannot be reconstructed. Correctness - Reports and exports silently truncated at PostgREST's 1000-row cap (db.max_rows); employee_history is already past it at ~800 staff. Every whole-table read now pages explicitly. - XLSX date cells were a day early: ExcelJS converts a Date to an Excel serial straight off getTime(), so a Date built at local midnight lands on the previous day's serial in any positive-offset zone. - Date handling is pinned to Europe/Vienna throughout, and date-only strings are formatted without a Date round-trip. The dashboard's YTD window was built by round-tripping a local Date through toISOString(), which shifted it a day early and dropped 31 December entirely. - Export routes parsed measure/group/split/eventType with unchecked `as` casts, so an unknown value reached column headers as `undefined` and the Content-Disposition filename. Parsed against the label maps now, with the filename slugged as a backstop. - toXlsx keyed columns by header text, silently dropping the second of any two columns sharing a name — split columns take their header from data. - The org chart tree walks had no cycle guard; nothing in the schema forbids a manager_id cycle, and one would hang the tab rather than misreport. - The login page reflected ?error= verbatim, letting anyone put arbitrary text on the real sign-in screen; messages are looked up by code now. - React Flow needs elementsSelectable on, or it sets pointer-events:none on the whole node and the expand control stops responding. UI - Mobile: the shell was unusable below lg — a fixed 236px margin pushed content off-screen with no mobile navigation at all. The sidebar is now a drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so iOS stops zooming on focus, and form grids stack. - Org chart nodes redesigned: per-kind accent stripes and icons, vacant roles called out, expand control moved to the bottom edge carrying the child count. - Pagination is windowed; it previously rendered one link per page (54 for the employee list, unbounded for the audit log). - Positions page reduced to open positions with a single "Besetzen" action. - The employee Organisation tab links into the org chart focused on that person, reusing the chart's existing search-match highlighting. Also included, uncommitted until now - Dependants, HR notes, academic titles, split address fields, position validity and role/employment fields, with their migrations and UI. - Docker/compose deployment setup, data-model and security-review docs.
428 lines
17 KiB
TypeScript
428 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { RotateCcw, X } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useMemo, useState } from "react";
|
||
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
|
||
import { Lookup } from "@/components/ui/Lookup";
|
||
import { useToast } from "@/components/ui/Toast";
|
||
import { fmtDate } from "@/lib/format";
|
||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||
|
||
type ChangeKind = "emp" | "team" | "abt" | "dept";
|
||
|
||
type PendingMove = {
|
||
id: string;
|
||
kind: ChangeKind;
|
||
label: string;
|
||
employeeIds: string[];
|
||
targetTeamId: string;
|
||
targetTeamLabel: string;
|
||
};
|
||
|
||
const KIND_LABELS: Record<ChangeKind, string> = {
|
||
emp: "Mitarbeiter:in(nen)",
|
||
team: "Ganzes Team",
|
||
abt: "Ganze Abteilung",
|
||
dept: "Ganzer Bereich",
|
||
};
|
||
|
||
type ReorgWorkbenchProps = {
|
||
employees: OrgEmployee[];
|
||
divisions: OrgDivision[];
|
||
departments: OrgDepartment[];
|
||
teams: OrgTeam[];
|
||
reorgScenarios: ReorgScenarioSummary[];
|
||
};
|
||
|
||
export function ReorgWorkbench({ employees, divisions, departments, teams, reorgScenarios }: ReorgWorkbenchProps) {
|
||
const { showToast } = useToast();
|
||
const router = useRouter();
|
||
|
||
const [name, setName] = useState("");
|
||
const [effectiveDate, setEffectiveDate] = useState("");
|
||
const [changeType, setChangeType] = useState<ChangeKind>("emp");
|
||
const [selectedEmployees, setSelectedEmployees] = useState<OrgEmployee[]>([]);
|
||
const [sourceTeamId, setSourceTeamId] = useState("");
|
||
const [sourceDeptId, setSourceDeptId] = useState("");
|
||
const [sourceDivisionId, setSourceDivisionId] = useState("");
|
||
const [targetDivisionId, setTargetDivisionId] = useState("");
|
||
const [targetTeamId, setTargetTeamId] = useState("");
|
||
const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);
|
||
const [applying, setApplying] = useState(false);
|
||
const [undoingId, setUndoingId] = useState<string | null>(null);
|
||
|
||
const divisionById = useMemo(() => new Map(divisions.map((d) => [d.id, d])), [divisions]);
|
||
const departmentById = useMemo(() => new Map(departments.map((d) => [d.id, d])), [departments]);
|
||
const teamById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
|
||
const teamDivisionId = useMemo(() => {
|
||
const deptDivision = new Map(departments.map((d) => [d.id, d.division_id]));
|
||
const m = new Map<string, string>();
|
||
for (const t of teams) m.set(t.id, deptDivision.get(t.department_id) ?? "");
|
||
return m;
|
||
}, [teams, departments]);
|
||
const teamsInTargetDivision = useMemo(
|
||
() => teams.filter((t) => teamDivisionId.get(t.id) === targetDivisionId),
|
||
[teams, teamDivisionId, targetDivisionId]
|
||
);
|
||
|
||
async function searchLocalEmployees(query: string): Promise<OrgEmployee[]> {
|
||
const q = query.trim().toLowerCase();
|
||
if (q.length < 2) return [];
|
||
return employees
|
||
.filter(
|
||
(e) =>
|
||
!selectedEmployees.some((s) => s.id === e.id) &&
|
||
(`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) || e.job_title.toLowerCase().includes(q))
|
||
)
|
||
.slice(0, 20);
|
||
}
|
||
|
||
function resolveEmployeeIds(): string[] {
|
||
if (changeType === "emp") return selectedEmployees.map((e) => e.id);
|
||
if (changeType === "team") return employees.filter((e) => e.team_id === sourceTeamId).map((e) => e.id);
|
||
if (changeType === "abt") {
|
||
const teamIds = new Set(teams.filter((t) => t.department_id === sourceDeptId).map((t) => t.id));
|
||
return employees.filter((e) => e.team_id && teamIds.has(e.team_id)).map((e) => e.id);
|
||
}
|
||
return employees.filter((e) => e.division_id === sourceDivisionId).map((e) => e.id);
|
||
}
|
||
|
||
function sourceLabel(): string {
|
||
if (changeType === "emp") return `${selectedEmployees.length} Mitarbeiter:in(nen)`;
|
||
if (changeType === "team") return `Team ${teamById.get(sourceTeamId)?.name ?? ""}`;
|
||
if (changeType === "abt") return `Abteilung ${departmentById.get(sourceDeptId)?.name ?? ""}`;
|
||
return `Bereich ${divisionById.get(sourceDivisionId)?.name ?? ""}`;
|
||
}
|
||
|
||
function handleAddMove() {
|
||
const employeeIds = resolveEmployeeIds();
|
||
if (employeeIds.length === 0) {
|
||
showToast("Keine Mitarbeiter:innen in der Auswahl gefunden.", "error");
|
||
return;
|
||
}
|
||
if (!targetTeamId) {
|
||
showToast("Bitte ein Ziel-Team wählen.", "error");
|
||
return;
|
||
}
|
||
const targetTeam = teamById.get(targetTeamId);
|
||
setPendingMoves((prev) => [
|
||
...prev,
|
||
{
|
||
id: crypto.randomUUID(),
|
||
kind: changeType,
|
||
label: sourceLabel(),
|
||
employeeIds,
|
||
targetTeamId,
|
||
targetTeamLabel: targetTeam?.name ?? "",
|
||
},
|
||
]);
|
||
setSelectedEmployees([]);
|
||
setSourceTeamId("");
|
||
setSourceDeptId("");
|
||
setSourceDivisionId("");
|
||
}
|
||
|
||
function removeMove(id: string) {
|
||
setPendingMoves((prev) => prev.filter((m) => m.id !== id));
|
||
}
|
||
|
||
const divisionBefore = useMemo(() => {
|
||
const m = new Map<string, number>();
|
||
for (const e of employees) m.set(e.division_id, (m.get(e.division_id) ?? 0) + 1);
|
||
return m;
|
||
}, [employees]);
|
||
|
||
const divisionDelta = useMemo(() => {
|
||
const m = new Map<string, number>();
|
||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||
for (const move of pendingMoves) {
|
||
const targetDivId = teamDivisionId.get(move.targetTeamId);
|
||
for (const empId of move.employeeIds) {
|
||
const emp = employeeById.get(empId);
|
||
if (!emp || !targetDivId || emp.division_id === targetDivId) continue;
|
||
m.set(emp.division_id, (m.get(emp.division_id) ?? 0) - 1);
|
||
m.set(targetDivId, (m.get(targetDivId) ?? 0) + 1);
|
||
}
|
||
}
|
||
return m;
|
||
}, [pendingMoves, employees, teamDivisionId]);
|
||
|
||
async function handleApply() {
|
||
if (!name || !effectiveDate || pendingMoves.length === 0) {
|
||
showToast("Bitte Name, Datum und mindestens eine Änderung angeben.", "error");
|
||
return;
|
||
}
|
||
setApplying(true);
|
||
const moves: ReorgMovePayload[] = pendingMoves.map((m) => ({
|
||
kind: m.kind,
|
||
label: m.label,
|
||
employee_ids: m.employeeIds,
|
||
target_team_id: m.targetTeamId,
|
||
}));
|
||
const result = await applyReorg({ name, effective_date: effectiveDate, moves });
|
||
setApplying(false);
|
||
if (result.success) {
|
||
showToast("Reorganisation durchgeführt.");
|
||
setPendingMoves([]);
|
||
setName("");
|
||
setEffectiveDate("");
|
||
router.refresh();
|
||
} else {
|
||
showToast(result.error ?? "Fehler bei der Reorganisation.", "error");
|
||
}
|
||
}
|
||
|
||
async function handleUndo(scenarioId: string) {
|
||
setUndoingId(scenarioId);
|
||
const result = await undoReorg({ scenario_id: scenarioId });
|
||
setUndoingId(null);
|
||
if (result.success) {
|
||
showToast("Reorganisation rückgängig gemacht.");
|
||
router.refresh();
|
||
} else {
|
||
showToast(result.error ?? "Fehler beim Rückgängigmachen.", "error");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="rounded border border-border bg-white p-4">
|
||
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Name der Reorganisation*</label>
|
||
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
|
||
<input
|
||
type="date"
|
||
value={effectiveDate}
|
||
onChange={(e) => setEffectiveDate(e.target.value)}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex gap-2">
|
||
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
|
||
<button
|
||
key={kind}
|
||
type="button"
|
||
onClick={() => setChangeType(kind)}
|
||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
|
||
}`}
|
||
>
|
||
{KIND_LABELS[kind]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Quelle</label>
|
||
{changeType === "emp" && (
|
||
<div className="flex flex-col gap-2">
|
||
<Lookup<OrgEmployee>
|
||
placeholder="Mitarbeiter:in suchen…"
|
||
onSearch={searchLocalEmployees}
|
||
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
|
||
renderResult={(e) => (
|
||
<div>
|
||
<div className="font-semibold text-ink">
|
||
{e.first_name} {e.last_name}
|
||
</div>
|
||
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||
</div>
|
||
)}
|
||
/>
|
||
{selectedEmployees.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{selectedEmployees.map((e) => (
|
||
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
|
||
{e.first_name} {e.last_name}
|
||
<button type="button" onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{changeType === "team" && (
|
||
<select value={sourceTeamId} onChange={(e) => setSourceTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||
<option value="">Team wählen…</option>
|
||
{teams.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{changeType === "abt" && (
|
||
<select value={sourceDeptId} onChange={(e) => setSourceDeptId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||
<option value="">Abteilung wählen…</option>
|
||
{departments.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{changeType === "dept" && (
|
||
<select
|
||
value={sourceDivisionId}
|
||
onChange={(e) => setSourceDivisionId(e.target.value)}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||
>
|
||
<option value="">Bereich wählen…</option>
|
||
{divisions.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Ziel-Bereich* / Ziel-Team*</label>
|
||
<div className="flex flex-col gap-2">
|
||
<select
|
||
value={targetDivisionId}
|
||
onChange={(e) => {
|
||
setTargetDivisionId(e.target.value);
|
||
setTargetTeamId("");
|
||
}}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||
>
|
||
<option value="">Ziel-Bereich wählen…</option>
|
||
{divisions.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select value={targetTeamId} onChange={(e) => setTargetTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||
<option value="">Ziel-Team wählen…</option>
|
||
{teamsInTargetDivision.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleAddMove}
|
||
className="mt-4 rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
|
||
>
|
||
+ Zur Reorganisation hinzufügen
|
||
</button>
|
||
</div>
|
||
|
||
{pendingMoves.length > 0 && (
|
||
<div className="rounded border border-border bg-white p-4">
|
||
<h2 className="mb-3 text-sm font-bold text-ink">Geplante Änderungen ({pendingMoves.length})</h2>
|
||
<ul className="mb-4 flex flex-col divide-y divide-border">
|
||
{pendingMoves.map((m) => (
|
||
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
|
||
<div>
|
||
<span className="mr-2 rounded-full bg-purple-bg px-2 py-0.5 text-xs font-semibold text-purple-text">{KIND_LABELS[m.kind]}</span>
|
||
<span className="text-ink">
|
||
{m.label} → {m.targetTeamLabel}
|
||
</span>
|
||
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
|
||
</div>
|
||
<button type="button" onClick={() => removeMove(m.id)} aria-label="Entfernen" className="text-ink-muted hover:text-danger-solid">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Auswirkung auf Headcount</h3>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-xs text-ink-muted">
|
||
<th className="py-1 pr-3">Bereich</th>
|
||
<th className="py-1 pr-3">Vorher</th>
|
||
<th className="py-1 pr-3">Nachher</th>
|
||
<th className="py-1 pr-3">Δ</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{Array.from(divisionDelta.entries())
|
||
.filter(([, delta]) => delta !== 0)
|
||
.map(([divId, delta]) => {
|
||
const before = divisionBefore.get(divId) ?? 0;
|
||
return (
|
||
<tr key={divId} className="border-t border-border">
|
||
<td className="py-1.5 pr-3 text-ink">{divisionById.get(divId)?.name}</td>
|
||
<td className="py-1.5 pr-3 text-ink-body">{before}</td>
|
||
<td className="py-1.5 pr-3 text-ink-body">{before + delta}</td>
|
||
<td className={`py-1.5 pr-3 font-semibold ${delta > 0 ? "text-success-text" : "text-danger-text"}`}>
|
||
{delta > 0 ? `+${delta}` : delta}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="mt-4 flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPendingMoves([])}
|
||
className="rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
|
||
>
|
||
Verwerfen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleApply}
|
||
disabled={applying}
|
||
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||
>
|
||
Reorganisation durchführen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{reorgScenarios.length > 0 && (
|
||
<div className="rounded border border-border bg-white p-4">
|
||
<h2 className="mb-3 text-sm font-bold text-ink">↩ Durchgeführte Reorganisationen – rückgängig machbar</h2>
|
||
<ul className="flex flex-col divide-y divide-border">
|
||
{reorgScenarios.map((s) => (
|
||
<li key={s.id} className="flex items-center justify-between py-2 text-sm">
|
||
<div>
|
||
<span className="font-semibold text-ink">{s.name}</span>
|
||
<span className="ml-2 text-xs text-ink-muted">
|
||
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleUndo(s.id)}
|
||
disabled={undoingId === s.id}
|
||
className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs font-semibold text-ink-body hover:bg-surface disabled:opacity-50"
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" />
|
||
Rückgängig machen
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|