8377ce03b5
Setzt die Referenzgrafik «Agenten_Bewirtschaftung_v2» um: die 36 digitalen Mitarbeitenden in konzentrischen Ringen, Kernteam innen und gold gerahmt. DATENBASIS Alle 36 Mitarbeitenden samt Porträts sind aus dem Konzeptdokument extrahiert. Die Stufen stecken bereits in den Quelldaten und decken sich exakt mit den Bereichen: lvl 1 = 7 Kernteam, lvl 2 = 18 Zentrale Dienste, lvl 3 = 7 GB Kommerziell, lvl 4 = 4 GB Wohnen. Auch die Reiterbeschriftungen sind wörtlich übernommen — keine neuen Stufennamen erfunden. Neu getrennt: `AgentDirectoryEntry` trägt die Stammdaten aller 36 für die Kreisdarstellung, `TeamAgent` bleibt das vollständige Dossier der sieben Kernteammitglieder. Für die übrigen 29 werden bewusst keine Aufgaben, Kanäle oder Tätigkeiten erfunden; ein Klick zeigt nur eine Vorschau aus dem Verzeichnis. ABWEICHUNG VOM AUFTRAG Der Auftrag nennt als Kernteam Ferdi, Sami, Leo, Bruno, Reto, Milo, Nora. Referenzgrafik und Konzeptdaten sagen übereinstimmend Ferdi, Sina, Lea, Bruno, Reto, Livia, Nora — dort tragen genau diese sieben `kern: true` und `lvl: 1`. Da der Auftrag die Grafik selbst als verbindlich erklärt und Namen laut §10 kein Primärschlüssel sind, folge ich Grafik und Daten. Sami, Leo und Milo kommen in keiner Quelle vor. OBERFLÄCHE - Teamübersicht: Beschreibungstext und «Demo zurücksetzen» entfernt, Kreis mit kumulativen Stufenreitern direkt unter dem Titel - Personalverwaltung: Umschaltung Dossieransicht / Organigramm über ?view=, Organigramm als oben abgeschnittener Halbkreis mit drehbarem Agentenrad (Ziehen, Touch, Pfeiltasten, Klick), aktiv ist wer unten mittig einrastet - Dossier: Untermenü direkt unter den Stammdaten, «Agentenbeschrieb» als erster Reiter, Zuständigkeiten auf drei gekürzt - Kanäle & Systeme: vier Karten pro Zeile, Kategorie-Sinnbild links vor dem Titel, Porträtgruppe statt Namensliste, ein Regler statt Verbinden/Trennen - Bearbeitungsverlauf: neutrale Karten, Farbe nur noch bei Ausnahmepriorität und abweichendem Status, mehr Weissraum Markenlogos gibt es im Repo nicht und wurden nicht nachgezeichnet — je Kategorie steht ein neutrales Sinnbild. Fehlendes Asset, bewusst so gelöst. Positionsdaten stehen ausschliesslich in `agentRingConfig.ts`; Vollkreis und Halbkreis teilen sich dieselbe Geometrie. AUFGERÄUMT `AgentCard` ist durch die Kreisdarstellung ersetzt und entfernt, seine Zusagen sind in `TeamOverviewRing.test.tsx` übergegangen — inklusive der wichtigsten: ausserhalb des Kernteams führt kein Klick in ein Dossier. Zwei echte Fehler unterwegs behoben: jsdom kennt keinen `ResizeObserver` (jetzt mit Rückfallebene statt Absturz), und das Rad las eine Ref im Render. 413 Tests grün, Build grün, ESLint über den Property-On-Code sauber, check:tokens unverändert bei 2249. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Box, Typography } from '@mui/material'
|
|
import { useNavigate } from 'react-router'
|
|
import type { AgentDirectoryEntry, AgentLevel } from '../../domain/agentDirectory'
|
|
import { AGENT_LEVEL_ORDER } from '../../domain/agentDirectory'
|
|
import { agentDirectory } from '../../mock-data/agentDirectory'
|
|
import { AgentRing } from './AgentRing'
|
|
import { AgentPreviewPopover } from './AgentPreviewPopover'
|
|
import { RING_SPECS, RING_REFERENCE_PX } from './agentRingConfig'
|
|
import { ROUTES } from '../../lib/constants'
|
|
import { BADGE_COLORS, DS_TEXT } from '../../lib/ds'
|
|
|
|
interface Props {
|
|
/** Bis zu welcher Stufe die Ringe sichtbar sind — kumulativ. */
|
|
level: AgentLevel
|
|
}
|
|
|
|
/** Misst die Containerbreite, damit Portraits und Schrift mitskalieren. */
|
|
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
|
const ref = useRef<HTMLDivElement | null>(null)
|
|
const [width, setWidth] = useState(RING_REFERENCE_PX)
|
|
|
|
useEffect(() => {
|
|
const node = ref.current
|
|
if (!node) return
|
|
|
|
// Ohne ResizeObserver bleibt die Referenzbreite stehen: die Darstellung ist
|
|
// dann nicht mitskaliert, aber vollständig bedienbar. Ein harter Absturz
|
|
// wegen einer fehlenden Browser-Schnittstelle wäre die schlechtere Antwort.
|
|
if (typeof ResizeObserver === 'undefined') {
|
|
setWidth(node.getBoundingClientRect().width || RING_REFERENCE_PX)
|
|
return
|
|
}
|
|
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
setWidth(entry.contentRect.width)
|
|
})
|
|
observer.observe(node)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
return [ref, width]
|
|
}
|
|
|
|
/**
|
|
* Kreisförmige Darstellung der digitalen Belegschaft.
|
|
*
|
|
* Aufbau nach der Referenzgrafik: Kernteam innen und gold gerahmt, danach die
|
|
* Zentralen Dienste, aussen die Spezialisten der Geschäftsbereiche. Welche Ringe
|
|
* sichtbar sind, bestimmt die gewählte Stufe — kumulativ, wie im Konzept.
|
|
*
|
|
* Nicht sichtbare Ringe werden ausgeblendet statt entfernt: so bleibt die
|
|
* Bewegung beim Stufenwechsel ruhig und der Kreis springt nicht in der Grösse.
|
|
*/
|
|
export function TeamOverviewRing({ level }: Props) {
|
|
const navigate = useNavigate()
|
|
const [ref, width] = useContainerWidth()
|
|
const [preview, setPreview] = useState<AgentDirectoryEntry | null>(null)
|
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
|
|
|
const scale = width / RING_REFERENCE_PX
|
|
const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level)
|
|
|
|
const agentsByLevel = useMemo(() => {
|
|
const map = new Map<AgentLevel, AgentDirectoryEntry[]>()
|
|
for (const spec of RING_SPECS) {
|
|
map.set(spec.level, agentDirectory.filter(a => a.level === spec.level))
|
|
}
|
|
return map
|
|
}, [])
|
|
|
|
const handleSelect = useCallback(
|
|
(agent: AgentDirectoryEntry, element: HTMLElement | null) => {
|
|
if (agent.isCore) {
|
|
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agent.id}`)
|
|
return
|
|
}
|
|
setPreview(agent)
|
|
setAnchorEl(element)
|
|
},
|
|
[navigate],
|
|
)
|
|
|
|
// Der Ringknoten meldet nur den Agenten; das auslösende Element holen wir uns
|
|
// aus dem Fokus, damit die Vorschau am angeklickten Portrait hängt.
|
|
const onNodeSelect = useCallback(
|
|
(agent: AgentDirectoryEntry) => {
|
|
const active = document.activeElement
|
|
handleSelect(agent, active instanceof HTMLElement ? active : null)
|
|
},
|
|
[handleSelect],
|
|
)
|
|
|
|
const closePreview = useCallback(() => {
|
|
setPreview(null)
|
|
setAnchorEl(null)
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<Box
|
|
ref={ref}
|
|
role="group"
|
|
aria-label="Digitale Belegschaft"
|
|
sx={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
maxWidth: RING_REFERENCE_PX,
|
|
aspectRatio: '1 / 1',
|
|
mx: 'auto',
|
|
}}
|
|
>
|
|
{RING_SPECS.map((spec, index) => (
|
|
<AgentRing
|
|
key={spec.level}
|
|
agents={agentsByLevel.get(spec.level) ?? []}
|
|
spec={spec}
|
|
scale={scale}
|
|
visible={index <= visibleUpTo}
|
|
onSelect={onNodeSelect}
|
|
/>
|
|
))}
|
|
|
|
{/* Nabe */}
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
left: '50%',
|
|
top: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: '19%',
|
|
height: '19%',
|
|
borderRadius: '50%',
|
|
bgcolor: DS_TEXT.brand,
|
|
color: DS_TEXT.inverted,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
textAlign: 'center',
|
|
px: 1,
|
|
}}
|
|
>
|
|
<Typography
|
|
sx={{
|
|
fontWeight: 700,
|
|
letterSpacing: '0.08em',
|
|
fontSize: `${Math.max(9, 13 * scale)}px`,
|
|
lineHeight: 1.2,
|
|
}}
|
|
>
|
|
PROPERTY{' '}
|
|
<Box component="span" sx={{ color: BADGE_COLORS.gold }}>ON</Box>
|
|
</Typography>
|
|
<Typography
|
|
sx={{
|
|
fontWeight: 700,
|
|
fontSize: `${Math.max(20, 40 * scale)}px`,
|
|
lineHeight: 1.05,
|
|
mt: 0.25,
|
|
}}
|
|
>
|
|
{agentDirectory.length}
|
|
</Typography>
|
|
<Typography sx={{ fontSize: `${Math.max(7.5, 10 * scale)}px`, lineHeight: 1.25, opacity: 0.85 }}>
|
|
digitale Mitarbeitende
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<AgentPreviewPopover agent={preview} anchorEl={anchorEl} onClose={closePreview} />
|
|
</>
|
|
)
|
|
}
|