Phase 4: Org chart (Mitarbeiter/Positionen/Reorganisation) + one more RLS bugfix

- components/orgchart/: 3-way segmented view sharing one server fetch
  (switching tabs doesn't refetch):
  - EmployeeTree: expand/collapse hierarchy from the CEO down, search with
    auto-expand-to-match and highlighting, "Bereiche anzeigen" /
    "Alles einklappen".
  - PositionTree: models the org *structure* (GF -> Bereichsleitung ->
    Abteilung -> Teamleitung -> grouped IC positions by title, expandable
    to the actual holders) independent of who's currently in it, plus
    dashed rows for open requisitions linking to /positions.
  - ReorgWorkbench: batch multiple moves (employees / whole team / whole
    department / whole division as source, always a specific team as
    target), live headcount-impact table, apply via the existing
    apply_reorg RPC, and an undo card wired to undo_reorg.

Bug found via live apply+undo testing: undo_reorg's cleanup DELETE on
employee_history silently matched zero rows, because that table has no
DELETE policy at all (by design, for audit immutability) - RLS filters
DELETE-eligible rows to none rather than erroring. Added
supabase/functions_3.sql: a policy scoped to hr_admin deleting only rows
that carry a reorg_scenario_id, so every other history event type stays
genuinely immutable. Verified live: apply moves an employee and updates
the headcount table correctly; undo reverts team/division/manager AND
now actually removes the Reorganisation history entries it created.

Simplification flagged here (not hidden): the spec's "Ganzes Team /
Ganze Abteilung / Ganzer Bereich" reorg moves the structural org unit
itself to a new division; this implementation resolves all four move
kinds down to individual employee moves against a specific target team,
since the schema's team->department->division chain doesn't support
freely reparenting a team object without also picking a department. The
workbench UI, headcount-impact math, and apply/undo all work correctly
under this model - only the exact "move the team as a unit" semantics
differs from the literal spec wording.
This commit is contained in:
2026-07-13 22:57:49 +02:00
parent 80cfaa1e04
commit 108da8d5e6
7 changed files with 866 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
"use client";
import { ChevronDown, ChevronRight, Search } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Avatar } from "@/components/ui/Avatar";
import type { OrgEmployee } from "./types";
export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const { childrenByManager, totalReportsById, root } = useMemo(() => {
const byManager = new Map<string, OrgEmployee[]>();
for (const e of employees) {
const key = e.manager_id ?? "__root__";
if (!byManager.has(key)) byManager.set(key, []);
byManager.get(key)!.push(e);
}
for (const list of byManager.values()) list.sort((a, b) => a.last_name.localeCompare(b.last_name));
const totals = new Map<string, number>();
function countTotal(id: string): number {
if (totals.has(id)) return totals.get(id)!;
const direct = byManager.get(id) ?? [];
let total = direct.length;
for (const child of direct) total += countTotal(child.id);
totals.set(id, total);
return total;
}
for (const e of employees) countTotal(e.id);
return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] };
}, [employees]);
const matchIds = useMemo(() => {
if (query.trim().length < 2) return null;
const q = query.trim().toLowerCase();
const matches = new Set<string>();
for (const e of employees) {
if (
`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) ||
e.job_title.toLowerCase().includes(q) ||
String(e.personnel_number).includes(q)
) {
matches.add(e.id);
}
}
return matches;
}, [query, employees]);
const ancestorExpandIds = useMemo(() => {
if (!matchIds) return null;
const byId = new Map(employees.map((e) => [e.id, e]));
const toExpand = new Set<string>();
for (const id of matchIds) {
let current = byId.get(id);
while (current?.manager_id) {
toExpand.add(current.manager_id);
current = byId.get(current.manager_id);
}
}
return toExpand;
}, [matchIds, employees]);
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function isExpanded(id: string): boolean {
return expanded.has(id) || (ancestorExpandIds?.has(id) ?? false);
}
function renderNode(e: OrgEmployee, depth: number) {
const children = childrenByManager.get(e.id) ?? [];
const hasChildren = children.length > 0;
const expandedNow = isExpanded(e.id);
const isMatch = matchIds?.has(e.id) ?? false;
const totalReports = totalReportsById.get(e.id) ?? 0;
return (
<div key={e.id}>
<div
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
style={{ paddingLeft: depth * 24 + 8 }}
>
{hasChildren ? (
<button type="button" onClick={() => toggle(e.id)} className="shrink-0 text-ink-muted">
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
<Link href={`/employees/${e.id}`} className="flex-1 hover:underline">
<span className="text-sm font-semibold text-ink">
{e.first_name} {e.last_name}
</span>
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
</Link>
{hasChildren && (
<span className="shrink-0 text-xs text-ink-muted">
{children.length} direkt · {totalReports} gesamt
</span>
)}
</div>
{hasChildren && expandedNow && <div>{children.map((c) => renderNode(c, depth + 1))}</div>}
</div>
);
}
return (
<div className="rounded border border-border bg-white p-4">
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-ink-muted" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Name, Pers.-Nr., Titel…"
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
/>
</div>
<button
type="button"
onClick={() => setExpanded(new Set(root.map((r) => r.id)))}
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
Bereiche anzeigen
</button>
<button
type="button"
onClick={() => setExpanded(new Set())}
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
Alles einklappen
</button>
</div>
<div>{root.map((r) => renderNode(r, 0))}</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import { EmployeeTree } from "./EmployeeTree";
import { PositionTree } from "./PositionTree";
import { ReorgWorkbench } from "./ReorgWorkbench";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
type View = "ma" | "pos" | "reo";
type OrgChartClientProps = {
employees: OrgEmployee[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
reorgScenarios: ReorgScenarioSummary[];
};
export function OrgChartClient({ employees, divisions, departments, teams, openPositions, reorgScenarios }: OrgChartClientProps) {
const [view, setView] = useState<View>("ma");
return (
<div className="flex flex-col gap-4">
<SegmentedControl<View>
value={view}
onChange={setView}
options={[
{ value: "ma", label: "Mitarbeiter" },
{ value: "pos", label: "Positionen" },
{ value: "reo", label: "Reorganisation" },
]}
/>
{view === "ma" && <EmployeeTree employees={employees} />}
{view === "pos" && (
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
)}
{view === "reo" && (
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
)}
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useState, type ReactNode } from "react";
import type { OpenPositionResolved } from "@/lib/positions";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
function TreeRow({
depth,
expandable,
expandedNow,
onToggle,
children,
}: {
depth: number;
expandable: boolean;
expandedNow: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface" style={{ paddingLeft: depth * 24 + 8 }}>
{expandable ? (
<button type="button" onClick={onToggle} className="shrink-0 text-ink-muted">
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{children}
</div>
);
}
type PositionTreeProps = {
employees: OrgEmployee[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
};
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const ceo = employees.find((e) => e.org_level === 0) ?? null;
const divisionHeadByDivision = new Map<string, OrgEmployee>();
const teamLeadByTeam = new Map<string, OrgEmployee>();
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
for (const e of employees) {
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
if (!e.is_lead && e.org_level === 3 && e.team_id) {
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
byTitle.get(e.job_title)!.push(e);
}
}
const openByTeam = new Map<string, OpenPositionResolved[]>();
for (const p of openPositions) {
if (!openByTeam.has(p.team_id)) openByTeam.set(p.team_id, []);
openByTeam.get(p.team_id)!.push(p);
}
return (
<div className="rounded border border-border bg-white p-4">
{ceo && (
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
<span className="text-xs text-ink-muted">
besetzt: {ceo.first_name} {ceo.last_name}
</span>
</TreeRow>
)}
{expanded.has("root") &&
divisions.map((div) => {
const head = divisionHeadByDivision.get(div.id);
const divKey = `div-${div.id}`;
return (
<div key={div.id}>
<TreeRow depth={1} expandable expandedNow={expanded.has(divKey)} onToggle={() => toggle(divKey)}>
<span className="text-sm font-semibold text-ink">Bereichsleitung {div.name}</span>
<span className="text-xs text-ink-muted">{head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"}</span>
</TreeRow>
{expanded.has(divKey) &&
departments
.filter((d) => d.division_id === div.id)
.map((dept) => {
const deptKey = `dept-${dept.id}`;
return (
<div key={dept.id}>
<TreeRow depth={2} expandable expandedNow={expanded.has(deptKey)} onToggle={() => toggle(deptKey)}>
<span className="text-sm text-ink">
{dept.org_number} · {dept.name}
</span>
</TreeRow>
{expanded.has(deptKey) &&
teams
.filter((t) => t.department_id === dept.id)
.map((team) => {
const teamKey = `team-${team.id}`;
const lead = teamLeadByTeam.get(team.id);
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
const openForTeam = openByTeam.get(team.id) ?? [];
return (
<div key={team.id}>
<TreeRow depth={3} expandable expandedNow={expanded.has(teamKey)} onToggle={() => toggle(teamKey)}>
<span className="text-sm text-ink">Teamleitung {team.name}</span>
<span className="text-xs text-ink-muted">
{lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"}
</span>
</TreeRow>
{expanded.has(teamKey) && (
<>
{Array.from(icsByTitle.entries()).map(([title, people]) => {
const groupKey = `${teamKey}-${title}`;
return (
<div key={title}>
<TreeRow
depth={4}
expandable={people.length > 0}
expandedNow={expanded.has(groupKey)}
onToggle={() => toggle(groupKey)}
>
<span className="text-sm text-ink">{title}</span>
<span className="text-xs text-ink-muted">{people.length}x besetzt</span>
</TreeRow>
{expanded.has(groupKey) &&
people.map((p) => (
<div key={p.id} className="py-1" style={{ paddingLeft: 5 * 24 + 8 }}>
<Link href={`/employees/${p.id}`} className="text-sm text-ink-body hover:text-brand-700 hover:underline">
{p.first_name} {p.last_name}
</Link>
</div>
))}
</div>
);
})}
{openForTeam.map((p) => (
<Link
key={p.id}
href="/positions"
className="block border-l-2 border-dashed border-brand-200 py-1 text-sm text-brand-700 hover:underline"
style={{ paddingLeft: 4 * 24 + 8 }}
>
+ {p.position_number} · {p.title}
</Link>
))}
</>
)}
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,427 @@
"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-2 gap-3">
<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>
);
}

View File

@@ -0,0 +1,17 @@
export type OrgEmployee = {
id: string;
personnel_number: number;
first_name: string;
last_name: string;
job_title: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
is_lead: boolean;
org_level: number;
};
export type OrgDivision = { id: string; org_number: string; name: string };
export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };