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:
173
components/orgchart/PositionTree.tsx
Normal file
173
components/orgchart/PositionTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user