diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx new file mode 100644 index 0000000..8e11b5a --- /dev/null +++ b/app/(app)/orgchart/page.tsx @@ -0,0 +1,42 @@ +import { OrgChartClient } from "@/components/orgchart/OrgChartClient"; +import { loadOpenPositions } from "@/lib/positions"; +import { createClient } from "@/lib/supabase/server"; + +export default async function OrgChartPage() { + const supabase = await createClient(); + + const [ + { data: employees }, + { data: divisions }, + { data: departments }, + { data: teams }, + openPositions, + { data: reorgScenarios }, + ] = await Promise.all([ + supabase + .from("employees_directory") + .select("id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level") + .in("status", ["Aktiv", "Karenz"]), + supabase.from("divisions").select("*").order("name"), + supabase.from("departments").select("*"), + supabase.from("teams").select("*"), + loadOpenPositions(supabase), + supabase + .from("reorg_scenarios") + .select("id, name, effective_date, applied, applied_at") + .eq("applied", true) + .order("applied_at", { ascending: false }) + .limit(5), + ]); + + return ( + + ); +} diff --git a/components/orgchart/EmployeeTree.tsx b/components/orgchart/EmployeeTree.tsx new file mode 100644 index 0000000..c6146ae --- /dev/null +++ b/components/orgchart/EmployeeTree.tsx @@ -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>(new Set()); + const [query, setQuery] = useState(""); + + const { childrenByManager, totalReportsById, root } = useMemo(() => { + const byManager = new Map(); + 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(); + 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(); + 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(); + 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 ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + + + + {e.first_name} {e.last_name} + + {e.job_title} + + {hasChildren && ( + + {children.length} direkt · {totalReports} gesamt + + )} +
+ {hasChildren && expandedNow &&
{children.map((c) => renderNode(c, depth + 1))}
} +
+ ); + } + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Name, Pers.-Nr., Titel…" + className="w-full text-sm text-ink outline-none placeholder:text-ink-muted" + /> +
+ + +
+
{root.map((r) => renderNode(r, 0))}
+
+ ); +} diff --git a/components/orgchart/OrgChartClient.tsx b/components/orgchart/OrgChartClient.tsx new file mode 100644 index 0000000..73a22e1 --- /dev/null +++ b/components/orgchart/OrgChartClient.tsx @@ -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("ma"); + + return ( +
+ + value={view} + onChange={setView} + options={[ + { value: "ma", label: "Mitarbeiter" }, + { value: "pos", label: "Positionen" }, + { value: "reo", label: "Reorganisation" }, + ]} + /> + {view === "ma" && } + {view === "pos" && ( + + )} + {view === "reo" && ( + + )} +
+ ); +} diff --git a/components/orgchart/PositionTree.tsx b/components/orgchart/PositionTree.tsx new file mode 100644 index 0000000..8f9b186 --- /dev/null +++ b/components/orgchart/PositionTree.tsx @@ -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 ( +
+ {expandable ? ( + + ) : ( + + )} + {children} +
+ ); +} + +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>(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(); + const teamLeadByTeam = new Map(); + const icsByTeamAndTitle = new Map>(); + 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(); + for (const p of openPositions) { + if (!openByTeam.has(p.team_id)) openByTeam.set(p.team_id, []); + openByTeam.get(p.team_id)!.push(p); + } + + return ( +
+ {ceo && ( + toggle("root")}> + Geschäftsführung + + besetzt: {ceo.first_name} {ceo.last_name} + + + )} + {expanded.has("root") && + divisions.map((div) => { + const head = divisionHeadByDivision.get(div.id); + const divKey = `div-${div.id}`; + return ( +
+ toggle(divKey)}> + Bereichsleitung {div.name} + {head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"} + + {expanded.has(divKey) && + departments + .filter((d) => d.division_id === div.id) + .map((dept) => { + const deptKey = `dept-${dept.id}`; + return ( +
+ toggle(deptKey)}> + + {dept.org_number} · {dept.name} + + + {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(); + const openForTeam = openByTeam.get(team.id) ?? []; + return ( +
+ toggle(teamKey)}> + Teamleitung {team.name} + + {lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"} + + + {expanded.has(teamKey) && ( + <> + {Array.from(icsByTitle.entries()).map(([title, people]) => { + const groupKey = `${teamKey}-${title}`; + return ( +
+ 0} + expandedNow={expanded.has(groupKey)} + onToggle={() => toggle(groupKey)} + > + {title} + {people.length}x besetzt + + {expanded.has(groupKey) && + people.map((p) => ( +
+ + {p.first_name} {p.last_name} + +
+ ))} +
+ ); + })} + {openForTeam.map((p) => ( + + + {p.position_number} · {p.title} + + ))} + + )} +
+ ); + })} +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/components/orgchart/ReorgWorkbench.tsx b/components/orgchart/ReorgWorkbench.tsx new file mode 100644 index 0000000..61d76b1 --- /dev/null +++ b/components/orgchart/ReorgWorkbench.tsx @@ -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 = { + 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("emp"); + const [selectedEmployees, setSelectedEmployees] = useState([]); + const [sourceTeamId, setSourceTeamId] = useState(""); + const [sourceDeptId, setSourceDeptId] = useState(""); + const [sourceDivisionId, setSourceDivisionId] = useState(""); + const [targetDivisionId, setTargetDivisionId] = useState(""); + const [targetTeamId, setTargetTeamId] = useState(""); + const [pendingMoves, setPendingMoves] = useState([]); + const [applying, setApplying] = useState(false); + const [undoingId, setUndoingId] = useState(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(); + 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 { + 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(); + 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(); + 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 ( +
+
+

Neue Reorganisation

+
+
+ + setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + setEffectiveDate(e.target.value)} + className="w-full rounded border border-border px-3 py-2 text-sm" + /> +
+
+ +
+ {(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => ( + + ))} +
+ +
+
+ + {changeType === "emp" && ( +
+ + placeholder="Mitarbeiter:in suchen…" + onSearch={searchLocalEmployees} + onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])} + renderResult={(e) => ( +
+
+ {e.first_name} {e.last_name} +
+
{e.job_title}
+
+ )} + /> + {selectedEmployees.length > 0 && ( +
+ {selectedEmployees.map((e) => ( + + {e.first_name} {e.last_name} + + + ))} +
+ )} +
+ )} + {changeType === "team" && ( + + )} + {changeType === "abt" && ( + + )} + {changeType === "dept" && ( + + )} +
+ +
+ +
+ + +
+
+
+ + +
+ + {pendingMoves.length > 0 && ( +
+

Geplante Änderungen ({pendingMoves.length})

+
    + {pendingMoves.map((m) => ( +
  • +
    + {KIND_LABELS[m.kind]} + + {m.label} → {m.targetTeamLabel} + + ({m.employeeIds.length} Mitarbeiter:innen) +
    + +
  • + ))} +
+ +

Auswirkung auf Headcount

+ + + + + + + + + + + {Array.from(divisionDelta.entries()) + .filter(([, delta]) => delta !== 0) + .map(([divId, delta]) => { + const before = divisionBefore.get(divId) ?? 0; + return ( + + + + + + + ); + })} + +
BereichVorherNachherΔ
{divisionById.get(divId)?.name}{before}{before + delta} 0 ? "text-success-text" : "text-danger-text"}`}> + {delta > 0 ? `+${delta}` : delta} +
+ +
+ + +
+
+ )} + + {reorgScenarios.length > 0 && ( +
+

↩ Durchgeführte Reorganisationen – rückgängig machbar

+
    + {reorgScenarios.map((s) => ( +
  • +
    + {s.name} + + wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)} + +
    + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/components/orgchart/types.ts b/components/orgchart/types.ts new file mode 100644 index 0000000..105dd96 --- /dev/null +++ b/components/orgchart/types.ts @@ -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 }; diff --git a/supabase/functions_3.sql b/supabase/functions_3.sql new file mode 100644 index 0000000..cf90cee --- /dev/null +++ b/supabase/functions_3.sql @@ -0,0 +1,15 @@ +-- Addendum to supabase/schema.sql + functions.sql — run after those. +-- +-- employee_history intentionally has no UPDATE/DELETE policy (§4.9's +-- "unveraenderbar" / append-only requirement). But undo_reorg needs to +-- remove the specific history rows a reorg created — found via live +-- testing: the DELETE inside undo_reorg silently matched 0 rows under RLS +-- (no error, since RLS just filters DELETE-eligible rows to none), leaving +-- Reorganisation entries behind after an otherwise-successful undo. +-- +-- Scope the exception as narrowly as possible: hr_admin may delete a +-- history row only if it carries a reorg_scenario_id, i.e. only rows +-- apply_reorg created. Eintritt/Austritt/Beförderung/etc. rows (always +-- reorg_scenario_id IS NULL) remain fully immutable. +create policy "history_delete_admin_reorg_undo" on employee_history for delete + using (is_hr_admin() and reorg_scenario_id is not null);