Files
alpenwerk-hr/components/orgchart/ReorgWorkbench.tsx
Maximilian Stubhan 79f0e19bf8 Org assignment history, mobile support, and a correctness pass
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.
2026-07-24 23:38:10 +02:00

428 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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