Farben, Schrift, Logo und Symbole folgen jetzt dem CD Manual 2022. An der Logik aendert sich nichts: keine Migration, keine Abfrage, keine Berechtigung. Die zwei Markenfarben stehen in §1.1 als #F69686 (Rosa) und #164194 (Blau). Welche davon was traegt, ist nicht gewaehlt, sondern nachgerechnet: Weiss auf Rosa kommt auf 2.18:1 und faellt damit auch fuer Grossschrift durch, Blau auf Rosa auf 4.34:1 und reicht fuer das Logo, nicht fuer Text. Schwarz auf Rosa sind 7.47:1, Weiss auf Blau 9.47:1. Rosa ist deshalb Flaeche, Blau ist Interaktion — zwei Skalen, brand-* und accent-*, statt einer geteilten. Von den 26 Stellen mit brand-50/100/200 sind nur sieben auf accent gewandert. Der Rest meint einen Zustand und keinen Markenton: die Zeile unter dem Zeiger, der gewaehlte Listeneintrag, der aktive Menuepunkt. Gegen die warme Seitenflaeche ist ein kuehler Ton dort deutlicher, und Blau heisst in dieser Oberflaeche ab jetzt "reagiert auf dich". Nach accent gingen die Faelle ohne eigene Bedeutung — "Geplant", "Offen", der neutrale Protokoll-Chip — und die offene Planstelle im Organigramm, die vorher ein blasses Blau war und damit wie eine schwaechere Person aussah. Die Funktionsfarben bleiben, was sie sind. Das Manual regelt die Identitaet, nicht die Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt. `info` bleibt bewusst tuerkis — Blau saehe ab jetzt bedienbar aus, und gemessen kaeme ein blaues info dem violetten Chip auf dE 6.8 nahe, also nicht unterscheidbar. Violett ist dabei nachgezogen: gegen den Fehler-Chip stand es bei dE 8.0, "Austritt" und "Befoerderung" waren im Vorbeigehen dieselbe blasse Flaeche. Jetzt dE 15.0. Die Schrift ist Barlow. Nachgezaehlt ist das Manual zu 95 % in DIN gesetzt (Regular 79 %, Bold 16 %); Helvetica Neue steht nur in den Visitenkarten und im Claim. DIN laesst sich nicht ausliefern — eine Drucklizenz deckt keinen Webfont —, und die freien Nachbauten der DIN 1451 sind Schilderschriften, bei 14 px in einer langen Tabelle schlechter lesbar als das, was sie ersetzen. Die Variable heisst --font-din und nicht --font-barlow: liegt eines Tages eine Web-Lizenz vor, ist der Wechsel diese eine Deklaration. Nebenbei zwei Dinge repariert, die vorher schon falsch waren. Die Umrandung von Eingabefeldern, Knoepfen und Suchfeldern lag bei 1.30:1 und damit unter den 3:1, die WCAG 1.4.11 fuer Bedienelemente verlangt; border-strong bringt 3.56:1. Und das mitgelieferte favicon.ico liess sich gar nicht bauen: eingebettet waren 24-Bit-RGB-PNG, waehrend der Kopf 32 bpp behauptete. Alle Rastersymbole liegen jetzt als RGBA vor, und ihr Blau ist auf den CD-Wert gezogen — samt der kantengeglaetteten Raender, indem je Pixel der Blauanteil bestimmt und neu gemischt wurde. Das Logo ist das Markenlogo (§4.1), nicht das Unternehmenslogo, das §3.2 fuer eine Anwendung mit der AG als Absender vorsaehe — es liegt nicht vor. Der Freiraum X/3 steckt im Bauteil selbst und nicht in den Aufrufstellen, sonst haengt seine Einhaltung daran, dass jede einzelne daran denkt. docs/farbschema.html zeigt Token, Kontraste und Bauteile nebeneinander und laesst sich ohne Server oeffnen. Nicht im Browser gesehen: Anmeldung laeuft ueber das Firmenkonto und die Datenbank ist von hier nicht erreichbar. Lint, Typen, Schemaabgleich, 524 Tests und der Build sind sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223 lines
9.7 KiB
TypeScript
223 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Avatar } from "@/components/ui/Avatar";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { SearchInput } from "@/components/ui/SearchInput";
|
|
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
|
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
|
import type { ChartNode, OrgEmployee } from "./types";
|
|
import { fmtName } from "@/lib/format";
|
|
|
|
type ViewMode = "list" | "graph";
|
|
|
|
export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmployee[]; focusId?: string | null }) {
|
|
// Seeded with the focus target so their own reports are already unfolded;
|
|
// the chain *above* them comes from ancestorExpandIds.
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => (focusId ? new Set([focusId]) : new Set()));
|
|
const [query, setQuery] = useState("");
|
|
const [mode, setMode] = useState<ViewMode>("list");
|
|
const focusRef = useRef<HTMLDivElement>(null);
|
|
|
|
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));
|
|
|
|
// Nothing in the schema forbids a manager_id cycle (A reports to B
|
|
// reports to A), and a reorg that moves a lead under one of their own
|
|
// reports would create one. Every walk below is recursive, so an
|
|
// unguarded cycle is an infinite recursion that hangs the tab rather
|
|
// than a wrong number — hence the on-path set.
|
|
const totals = new Map<string, number>();
|
|
function countTotal(id: string, path: Set<string>): number {
|
|
const memo = totals.get(id);
|
|
if (memo !== undefined) return memo;
|
|
if (path.has(id)) return 0;
|
|
path.add(id);
|
|
let total = 0;
|
|
for (const child of byManager.get(id) ?? []) total += 1 + countTotal(child.id, path);
|
|
path.delete(id);
|
|
totals.set(id, total);
|
|
return total;
|
|
}
|
|
for (const e of employees) countTotal(e.id, new Set());
|
|
|
|
return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] };
|
|
}, [employees]);
|
|
|
|
// Both badges name a person by id, so the lookup is shared rather than
|
|
// rebuilt per node.
|
|
const nameById = useMemo(() => new Map(employees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])), [employees]);
|
|
const nameOf = useCallback((id: string | null) => (id ? nameById.get(id) : undefined), [nameById]);
|
|
|
|
const matchIds = useMemo(() => {
|
|
// A focus target behaves exactly like a search hit — same ancestor
|
|
// auto-expand, same ring, same fitView in graph mode — until the user
|
|
// starts typing, at which point their own search takes over.
|
|
if (query.trim().length < 2) return focusId ? new Set([focusId]) : null;
|
|
const q = query.trim().toLowerCase();
|
|
const matches = new Set<string>();
|
|
for (const e of employees) {
|
|
// Beide Reihenfolgen: angezeigt wird „Winkler, Hannah", im Kopf hat man
|
|
// aber je nach Anlass das eine oder das andere zuerst. Wer abtippt, was
|
|
// er sieht, soll ebenso fündig werden wie jemand, der „hannah winkler"
|
|
// eingibt.
|
|
const vorNach = `${e.first_name} ${e.last_name}`.toLowerCase();
|
|
const nachVor = fmtName(e.first_name, e.last_name).toLowerCase();
|
|
if (
|
|
vorNach.includes(q) ||
|
|
nachVor.includes(q) ||
|
|
// Ohne den Beistrich, falls jemand ihn beim Abtippen weglässt.
|
|
nachVor.replace(",", "").includes(q) ||
|
|
e.job_title.toLowerCase().includes(q) ||
|
|
String(e.personnel_number).includes(q)
|
|
) {
|
|
matches.add(e.id);
|
|
}
|
|
}
|
|
return matches;
|
|
}, [query, employees, focusId]);
|
|
|
|
// The chain above the focused person is auto-expanded, so the row only
|
|
// exists after that render — scroll once it does.
|
|
useEffect(() => {
|
|
if (!focusId) return;
|
|
focusRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
}, [focusId, mode]);
|
|
|
|
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.has(current.manager_id)) {
|
|
toExpand.add(current.manager_id);
|
|
current = byId.get(current.manager_id);
|
|
}
|
|
}
|
|
return toExpand;
|
|
}, [matchIds, employees]);
|
|
|
|
// useCallback-stable: GraphOrgChart's layout memo depends on this
|
|
// reference, so an unstable function would force a Dagre re-layout on
|
|
// every unrelated re-render.
|
|
const toggle = useCallback((id: string) => {
|
|
setExpanded((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const isExpanded = useCallback((id: string): boolean => expanded.has(id) || (ancestorExpandIds?.has(id) ?? false), [expanded, ancestorExpandIds]);
|
|
|
|
const chartTree = useMemo<ChartNode[]>(() => {
|
|
function toChartNode(e: OrgEmployee, ancestors: Set<string>): ChartNode {
|
|
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
|
|
const total = totalReportsById.get(e.id) ?? 0;
|
|
const nextAncestors = new Set(ancestors).add(e.id);
|
|
return {
|
|
id: e.id,
|
|
kind: "person",
|
|
label: fmtName(e.first_name, e.last_name),
|
|
sublabel: e.job_title,
|
|
href: `/employees/${e.id}`,
|
|
avatar: { firstName: e.first_name, lastName: e.last_name },
|
|
totalReports: total,
|
|
matched: matchIds?.has(e.id) ?? false,
|
|
absent: e.absent,
|
|
// Who is standing in for this absent person: their own acting
|
|
// manager, which is the same one their reports moved to.
|
|
coveredBy: e.absent ? nameOf(e.manager_id) : undefined,
|
|
coveringFor: nameOf(e.formal_manager_id),
|
|
children: children.map((c) => toChartNode(c, nextAncestors)),
|
|
};
|
|
}
|
|
return root.map((r) => toChartNode(r, new Set()));
|
|
}, [root, childrenByManager, totalReportsById, matchIds, nameOf]);
|
|
|
|
function renderNode(e: OrgEmployee, depth: number, ancestors: Set<string>) {
|
|
const children = ancestors.has(e.id) ? [] : (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
|
|
ref={e.id === focusId ? focusRef : undefined}
|
|
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-accent-200" : ""}`}
|
|
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="min-w-0 flex-1 hover:underline">
|
|
<span className="text-sm font-semibold text-ink">
|
|
{fmtName(e.first_name, e.last_name)}
|
|
</span>
|
|
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
|
|
</Link>
|
|
{/* The stand-in arrangement is stated on both sides: on the absent
|
|
person (whose team has moved away) and on anyone now reporting
|
|
elsewhere — otherwise the chart would silently show a reporting
|
|
line that is not the recorded one. */}
|
|
{e.absent && (
|
|
<span className="shrink-0 rounded-full bg-warning-bg px-2 py-0.5 text-[11px] font-semibold text-warning-text">
|
|
Abwesend{nameOf(e.manager_id) ? ` · Vertretung: ${nameOf(e.manager_id)}` : ""}
|
|
</span>
|
|
)}
|
|
{e.formal_manager_id && (
|
|
<span className="shrink-0 rounded-full bg-info-bg px-2 py-0.5 text-[11px] font-semibold text-info-text">
|
|
Vertretung für {nameOf(e.formal_manager_id)}
|
|
</span>
|
|
)}
|
|
{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, new Set(ancestors).add(e.id)))}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="rounded border border-border bg-white p-4">
|
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
|
<SearchInput label="Organigramm durchsuchen" placeholder="Name, Pers.-Nr., Titel…" value={query} onChange={setQuery} />
|
|
<Button variant="secondary" size="sm" onClick={() => setExpanded(new Set(root.map((r) => r.id)))}>
|
|
Bereiche anzeigen
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => setExpanded(new Set())}>
|
|
Alles einklappen
|
|
</Button>
|
|
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
|
</div>
|
|
{mode === "list" ? (
|
|
<div>{root.map((r) => renderNode(r, 0, new Set()))}</div>
|
|
) : (
|
|
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} matchedIds={matchIds} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|