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>
139 lines
5.8 KiB
TypeScript
139 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
|
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { memo } from "react";
|
|
import { Avatar } from "@/components/ui/Avatar";
|
|
import { NODE_DIMENSIONS } from "./graphLayout";
|
|
import type { ChartNode, ChartNodeKind } from "./types";
|
|
|
|
export type OrgChartNodeData = {
|
|
chartNode: ChartNode;
|
|
expanded: boolean;
|
|
hasChildren: boolean;
|
|
childCount: number;
|
|
onToggle: (id: string) => void;
|
|
};
|
|
|
|
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
|
|
|
|
// One accent colour per node kind, carried by a left stripe. It is what makes
|
|
// the four kinds separable at a glance when the chart is zoomed out far
|
|
// enough that the text has stopped being legible.
|
|
const KIND_ACCENT: Record<ChartNodeKind, string> = {
|
|
person: "bg-brand-500",
|
|
role: "bg-purple-text",
|
|
group: "bg-ink-muted",
|
|
// Rosa statt einer blassen Blaustufe: eine offene Stelle ist kein
|
|
// schwaecheres Abbild einer Person, sondern etwas anderes. Gegen das
|
|
// Blau der Person ist der Unterschied auch dann noch zu sehen, wenn
|
|
// herausgezoomt nur noch die Streifen uebrig sind.
|
|
vacancy: "bg-accent-500",
|
|
};
|
|
|
|
const KIND_SHELL: Record<ChartNodeKind, string> = {
|
|
person: "border-border bg-white",
|
|
role: "border-border bg-white",
|
|
group: "border-border-subtle bg-surface",
|
|
vacancy: "border-dashed border-accent-500 bg-accent-50",
|
|
};
|
|
|
|
// React Flow re-renders node components on every pan/zoom frame — memo is
|
|
// required, not just tidy, to keep that smooth at a few hundred nodes.
|
|
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
|
|
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
|
|
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode;
|
|
const isMatch = chartNode.matched ?? false;
|
|
const { width, height } = NODE_DIMENSIONS[kind];
|
|
|
|
const content = (
|
|
<>
|
|
{kind === "person" && avatar ? (
|
|
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
|
|
) : kind === "vacancy" ? (
|
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500 text-accent-700">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</span>
|
|
) : kind === "role" ? (
|
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-purple-bg text-purple-text">
|
|
<UserRound className="h-3.5 w-3.5" />
|
|
</span>
|
|
) : (
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-ink-muted">
|
|
<Building2 className="h-3.5 w-3.5" />
|
|
</span>
|
|
)}
|
|
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
<span title={label} className="truncate text-[13px] font-bold leading-tight text-ink">
|
|
{label}
|
|
</span>
|
|
{sublabel && (
|
|
<span
|
|
title={sublabel}
|
|
className={`truncate text-[11px] leading-tight ${vacant ? "font-semibold text-warning-text" : "text-ink-muted"}`}
|
|
>
|
|
{sublabel}
|
|
</span>
|
|
)}
|
|
{/* The stand-in replaces the report count rather than crowding in
|
|
next to it: on a card this size the arrangement is the more
|
|
important of the two, and it only appears while one is in force. */}
|
|
{absent ? (
|
|
<span className="mt-0.5 truncate text-[10px] font-semibold text-warning-text">
|
|
Abwesend{coveredBy ? ` · Vertretung: ${coveredBy}` : ""}
|
|
</span>
|
|
) : coveringFor ? (
|
|
<span className="mt-0.5 truncate text-[10px] font-semibold text-info-text">Vertretung für {coveringFor}</span>
|
|
) : (
|
|
totalReports !== undefined &&
|
|
totalReports > 0 && (
|
|
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted">
|
|
{childCount} direkt · {totalReports} gesamt
|
|
</span>
|
|
)
|
|
)}
|
|
</span>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
style={{ width, height }}
|
|
className={`group relative flex items-center overflow-visible rounded-lg border shadow-sm transition-shadow hover:shadow-md ${
|
|
KIND_SHELL[kind]
|
|
} ${isMatch ? "ring-2 ring-brand-500 ring-offset-1" : ""}`}
|
|
>
|
|
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
|
|
|
|
{/* Accent stripe, inset so it follows the card's rounded corner. */}
|
|
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
|
|
|
|
{href ? (
|
|
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
|
|
{content}
|
|
</Link>
|
|
) : (
|
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
|
|
)}
|
|
|
|
{/* Overhangs the bottom edge, sitting on the connector to its children —
|
|
the conventional org-chart affordance, and a 28px touch target. */}
|
|
{hasChildren && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggle(id)}
|
|
aria-expanded={expanded}
|
|
aria-label={expanded ? `${label} zuklappen` : `${label} aufklappen (${childCount})`}
|
|
className="nodrag nopan absolute -bottom-3.5 left-1/2 z-10 flex h-7 min-w-7 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-white px-1.5 text-[11px] font-bold text-ink-body shadow-sm transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white"
|
|
>
|
|
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : childCount}
|
|
</button>
|
|
)}
|
|
|
|
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!invisible" />
|
|
</div>
|
|
);
|
|
});
|