diff --git a/src/assets/team/ada.jpg b/src/assets/team/ada.jpg new file mode 100644 index 0000000..bd6c48f Binary files /dev/null and b/src/assets/team/ada.jpg differ diff --git a/src/assets/team/beat.jpg b/src/assets/team/beat.jpg new file mode 100644 index 0000000..2da71a2 Binary files /dev/null and b/src/assets/team/beat.jpg differ diff --git a/src/assets/team/carla.jpg b/src/assets/team/carla.jpg new file mode 100644 index 0000000..2c6923f Binary files /dev/null and b/src/assets/team/carla.jpg differ diff --git a/src/assets/team/diego.jpg b/src/assets/team/diego.jpg new file mode 100644 index 0000000..7f0c634 Binary files /dev/null and b/src/assets/team/diego.jpg differ diff --git a/src/assets/team/dora.jpg b/src/assets/team/dora.jpg new file mode 100644 index 0000000..1026b5a Binary files /dev/null and b/src/assets/team/dora.jpg differ diff --git a/src/assets/team/emil.jpg b/src/assets/team/emil.jpg new file mode 100644 index 0000000..4a4bd53 Binary files /dev/null and b/src/assets/team/emil.jpg differ diff --git a/src/assets/team/fabio.jpg b/src/assets/team/fabio.jpg new file mode 100644 index 0000000..f5c8497 Binary files /dev/null and b/src/assets/team/fabio.jpg differ diff --git a/src/assets/team/filip.jpg b/src/assets/team/filip.jpg new file mode 100644 index 0000000..c30087f Binary files /dev/null and b/src/assets/team/filip.jpg differ diff --git a/src/assets/team/gian.jpg b/src/assets/team/gian.jpg new file mode 100644 index 0000000..6ac803f Binary files /dev/null and b/src/assets/team/gian.jpg differ diff --git a/src/assets/team/ida.jpg b/src/assets/team/ida.jpg new file mode 100644 index 0000000..89a2a71 Binary files /dev/null and b/src/assets/team/ida.jpg differ diff --git a/src/assets/team/jana.jpg b/src/assets/team/jana.jpg new file mode 100644 index 0000000..9582461 Binary files /dev/null and b/src/assets/team/jana.jpg differ diff --git a/src/assets/team/kira.jpg b/src/assets/team/kira.jpg new file mode 100644 index 0000000..8ae96c8 Binary files /dev/null and b/src/assets/team/kira.jpg differ diff --git a/src/assets/team/lars.jpg b/src/assets/team/lars.jpg new file mode 100644 index 0000000..c0e4024 Binary files /dev/null and b/src/assets/team/lars.jpg differ diff --git a/src/assets/team/mia.jpg b/src/assets/team/mia.jpg new file mode 100644 index 0000000..7fce896 Binary files /dev/null and b/src/assets/team/mia.jpg differ diff --git a/src/assets/team/nadia.jpg b/src/assets/team/nadia.jpg new file mode 100644 index 0000000..e44179f Binary files /dev/null and b/src/assets/team/nadia.jpg differ diff --git a/src/assets/team/oskar.jpg b/src/assets/team/oskar.jpg new file mode 100644 index 0000000..ffa5284 Binary files /dev/null and b/src/assets/team/oskar.jpg differ diff --git a/src/assets/team/otto.jpg b/src/assets/team/otto.jpg new file mode 100644 index 0000000..32df111 Binary files /dev/null and b/src/assets/team/otto.jpg differ diff --git a/src/assets/team/paul.jpg b/src/assets/team/paul.jpg new file mode 100644 index 0000000..38f73a2 Binary files /dev/null and b/src/assets/team/paul.jpg differ diff --git a/src/assets/team/pia.jpg b/src/assets/team/pia.jpg new file mode 100644 index 0000000..df6f285 Binary files /dev/null and b/src/assets/team/pia.jpg differ diff --git a/src/assets/team/rea.jpg b/src/assets/team/rea.jpg new file mode 100644 index 0000000..9756744 Binary files /dev/null and b/src/assets/team/rea.jpg differ diff --git a/src/assets/team/rico.jpg b/src/assets/team/rico.jpg new file mode 100644 index 0000000..fa38ae4 Binary files /dev/null and b/src/assets/team/rico.jpg differ diff --git a/src/assets/team/sven.jpg b/src/assets/team/sven.jpg new file mode 100644 index 0000000..6ab95b1 Binary files /dev/null and b/src/assets/team/sven.jpg differ diff --git a/src/assets/team/tino.jpg b/src/assets/team/tino.jpg new file mode 100644 index 0000000..8247951 Binary files /dev/null and b/src/assets/team/tino.jpg differ diff --git a/src/assets/team/uli.jpg b/src/assets/team/uli.jpg new file mode 100644 index 0000000..574226f Binary files /dev/null and b/src/assets/team/uli.jpg differ diff --git a/src/assets/team/vela.jpg b/src/assets/team/vela.jpg new file mode 100644 index 0000000..c93ffda Binary files /dev/null and b/src/assets/team/vela.jpg differ diff --git a/src/assets/team/vera.jpg b/src/assets/team/vera.jpg new file mode 100644 index 0000000..3acac13 Binary files /dev/null and b/src/assets/team/vera.jpg differ diff --git a/src/assets/team/vinz.jpg b/src/assets/team/vinz.jpg new file mode 100644 index 0000000..1b66e54 Binary files /dev/null and b/src/assets/team/vinz.jpg differ diff --git a/src/assets/team/vito.jpg b/src/assets/team/vito.jpg new file mode 100644 index 0000000..acb08db Binary files /dev/null and b/src/assets/team/vito.jpg differ diff --git a/src/assets/team/zeno.jpg b/src/assets/team/zeno.jpg new file mode 100644 index 0000000..14225b8 Binary files /dev/null and b/src/assets/team/zeno.jpg differ diff --git a/src/components/team/AgentAvatar.tsx b/src/components/team/AgentAvatar.tsx index dd04d96..0d30817 100644 --- a/src/components/team/AgentAvatar.tsx +++ b/src/components/team/AgentAvatar.tsx @@ -1,73 +1,97 @@ import { memo } from 'react' import { Avatar, Box, Tooltip } from '@mui/material' import { Check } from 'lucide-react' -import type { TeamAgent } from '../../domain/teamAgent' -import { AgentStatus } from '../../domain/teamAgent' +import type { AgentAvatarSubject } from '../../domain/agentDirectory' +import type { AgentStatus, AgentAvatarTone } from '../../domain/teamAgent' +import { AgentStatus as Status } from '../../domain/teamAgent' import { AVATAR_TONE_COLOR } from './teamTokens' import { AGENT_PHOTOS } from './agentPhotos' import { AGENT_STATUS_LABELS } from '../../lib/constants' -import { DS_BG, DS_TEXT } from '../../lib/ds' +import { DS_BG, DS_TEXT, DS_BORDER } from '../../lib/ds' -const SIZES = { +const NAMED_SIZES = { + xsmall: 22, small: 28, medium: 40, large: 64, + xlarge: 96, } as const -export type AgentAvatarSize = keyof typeof SIZES +export type AgentAvatarSize = keyof typeof NAMED_SIZES | number interface Props { - agent: TeamAgent - size?: AgentAvatarSize /** - * Blendet den Statuspunkt ein. Auf der Teamübersicht ist er verbindlich (§6.2): - * ein grüner Haken für aktiv, sonst der Hinweis «Pausiert». + * Bewusst die Minimalform: so passt sowohl ein vollständiger `TeamAgent` als + * auch ein blosser Verzeichniseintrag hinein. Zwei fast identische + * Avatarkomponenten für dieselbe Sache wären reine Duplikation. */ + agent: AgentAvatarSubject + size?: AgentAvatarSize + /** Nur die sieben Kernteammitglieder führen einen Betriebszustand. */ + status?: AgentStatus showStatus?: boolean + /** Farbe der Initialen-Fläche, falls kein Porträt vorhanden ist. */ + tone?: AgentAvatarTone + /** Ringe mit vielen Porträts laden verzögert — nur sichtbare zuerst. */ + loading?: 'lazy' | 'eager' + /** Zusätzlicher Rahmen, etwa zur Hervorhebung des Kernteams. */ + ringColor?: string + ringWidth?: number } function initialsOf(name: string): string { return name.trim().slice(0, 2).toUpperCase() } -/** - * Es gibt keine Porträtbilder für digitale Mitarbeiter — und erfundene - * Gesichter wären in einer Kundendemo irreführend. Stattdessen Initialen auf - * einer je Mitarbeiter festen Farbe. - */ -export const AgentAvatar = memo(function AgentAvatar({ agent, size = 'medium', showStatus = false }: Props) { - const px = SIZES[size] - const isActive = agent.status === AgentStatus.ACTIVE - const statusLabel = AGENT_STATUS_LABELS[agent.status] ?? agent.status - const badgePx = size === 'large' ? 20 : 14 +function resolveSize(size: AgentAvatarSize): number { + return typeof size === 'number' ? size : NAMED_SIZES[size] +} + +export const AgentAvatar = memo(function AgentAvatar({ + agent, + size = 'medium', + status, + showStatus = false, + tone, + loading = 'lazy', + ringColor, + ringWidth = 2, +}: Props) { + const px = resolveSize(size) const photo = AGENT_PHOTOS[agent.id] const label = `${agent.name}, ${agent.role}` + const isActive = status === Status.ACTIVE + const statusLabel = status ? (AGENT_STATUS_LABELS[status] ?? status) : '' + const badgePx = px >= 64 ? 20 : 14 return ( {/* Der zugängliche Name hängt an genau einem Element, nie an zweien: - mit Porträt trägt ihn das von MUI erzeugte über `alt`, ohne - Porträt der Container über role="img" und aria-label. `alt` allein - genügt nämlich nicht — ohne `src` rendert MUI gar kein , und der - Wert landet nirgends im DOM. */} + mit Porträt am erzeugten über `alt`, ohne Porträt am Container + über role="img" und aria-label. `alt` allein genügt nämlich nicht — + ohne `src` rendert MUI gar kein , der Wert landet nirgends. */} {/* Fällt das Bild aus, zeigt MUI wieder die Initialen statt eines Lochs. */} {initialsOf(agent.name)} - {showStatus && ( + {showStatus && status && ( { + const entries = agentIds + .map(id => agentDirectory.find(a => a.id === id)) + .filter((a): a is NonNullable => !!a) + return { + visible: entries.slice(0, MAX_VISIBLE), + overflow: entries.slice(MAX_VISIBLE), + allNames: entries.map(a => a.name).join(', '), + } + }, [agentIds]) + + if (visible.length === 0) { + return ( + + Noch keinem Mitarbeitenden zugewiesen + + ) + } + + return ( + + {label && ( + + {label} + + )} + + + {visible.map((agent) => ( + + + + + + ))} + + {overflow.length > 0 && ( + a.name).join(', ')} arrow> + + +{overflow.length} + + + )} + + + ) +}) diff --git a/src/components/team/AgentCard.tsx b/src/components/team/AgentCard.tsx deleted file mode 100644 index a4852b5..0000000 --- a/src/components/team/AgentCard.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { memo } from 'react' -import { Box, Button, Typography } from '@mui/material' -import { ArrowRight } from 'lucide-react' -import type { TeamAgent } from '../../domain/teamAgent' -import { AgentAvatar } from './AgentAvatar' -import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges' -import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' -import { formatTeamRelative } from '../../lib/teamClock' - -interface Props { - agent: TeamAgent - onOpen: (agentId: string) => void -} - -/** - * Agentenkarte im Kernteam-Raster (§6.2). - * - * Der Aktivstatus steht doppelt: als Haken am Avatar und als Textbadge. Farbe - * allein darf einen Zustand nie tragen (§2.3, §18). - */ -export const AgentCard = memo(function AgentCard({ agent, onOpen }: Props) { - const headline = agent.metrics.find(m => m.id === agent.headlineMetricId) ?? agent.metrics[0] - - return ( - - - - - - {agent.name} - - - {agent.role} - - - Personalnummer {agent.personnelNumber} - - - - - - {agent.shortDescription} - - - - - - - - {headline && ( - - - {headline.value} - - - {headline.label} - - - )} - - - - {agent.lastRun ? `Zuletzt aktiv ${formatTeamRelative(agent.lastRun)}` : 'Noch nicht gelaufen'} - - - - - ) -}) diff --git a/src/components/team/AgentDescriptionTab.tsx b/src/components/team/AgentDescriptionTab.tsx new file mode 100644 index 0000000..ac3b853 --- /dev/null +++ b/src/components/team/AgentDescriptionTab.tsx @@ -0,0 +1,151 @@ +/** + * Property On — Reiter «Agentenbeschrieb» im Personaldossier. + * + * Der Einstieg ins Dossier: Er beantwortet in wenigen Zeilen, wofür ein + * digitaler Mitarbeiter zuständig ist, womit er arbeitet, was dabei herauskommt + * und wie viel davon zuletzt angefallen ist. Sämtliche Angaben stammen + * unverändert aus dem Personaldossier — hier wird nichts formuliert, was nicht + * in den Daten steht. + * + * Zwei bewusste Entscheide: + * + * 1. Der Zweck steht genau einmal auf der Seite. Die geteilte Informationsbox + * zeigt ihn bereits über den drei Spalten; der Kurzbeschrieb darüber + * entfällt deshalb, sobald er inhaltlich dasselbe sagt. + * 2. Die Zuständigkeiten stehen gekürzt. Je nach Mitarbeiter sind bis zu neun + * Punkte hinterlegt — als vollständige Liste verdrängen sie die eigentliche + * Aussage nach unten. Sichtbar sind die ersten drei, der Rest wird beziffert. + * Die Daten selbst bleiben unangetastet. + */ + +import { memo, useMemo } from 'react' +import { Box, Typography } from '@mui/material' +import type { AgentMetric, TeamAgent } from '../../domain/teamAgent' +import { AgentInfoBox } from './AgentInfoBox' +import { DS_BORDER, DS_TEXT } from '../../lib/ds' + +/** Sichtbare Zuständigkeiten; alles Weitere wird nur noch gezählt. */ +const VISIBLE_RESPONSIBILITIES = 3 + +/** + * Doppelung erkennen, ohne Text zu erfinden: Kurze Wörter tragen im Deutschen + * kaum Bedeutung («und», «bis», «der»), deshalb zählen nur längere. Deckt der + * Zweck den Kurzbeschrieb weitgehend ab, ist der Kurzbeschrieb eine Wiederholung. + */ +const MIN_WORD_LENGTH = 5 +const REDUNDANCY_THRESHOLD = 0.6 + +function contentWords(text: string): Set { + const words = text + .toLowerCase() + .split(/[^a-zäöüéèàç]+/) + .filter(word => word.length >= MIN_WORD_LENGTH) + return new Set(words) +} + +function saysTheSame(shortText: string, longText: string): boolean { + const own = contentWords(shortText) + if (own.size === 0) return true + const other = contentWords(longText) + let shared = 0 + for (const word of own) if (other.has(word)) shared += 1 + return shared / own.size >= REDUNDANCY_THRESHOLD +} + +// ── Bausteine ───────────────────────────────────────────────────────────────── + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ) +} + +const MetricItem = memo(function MetricItem({ metric }: { metric: AgentMetric }) { + return ( + + + {metric.value} + + + {metric.label} + + + ) +}) + +const ResponsibilityItem = memo(function ResponsibilityItem({ text }: { text: string }) { + return ( + + {text} + + ) +}) + +// ── Reiter ──────────────────────────────────────────────────────────────────── + +export function AgentDescriptionTab({ agent }: { agent: TeamAgent }) { + const showShortDescription = useMemo( + () => !saysTheSame(agent.shortDescription, agent.profile.purpose), + [agent.shortDescription, agent.profile.purpose], + ) + + const visibleResponsibilities = useMemo( + () => agent.responsibilities.slice(0, VISIBLE_RESPONSIBILITIES), + [agent.responsibilities], + ) + + const hiddenResponsibilities = agent.responsibilities.length - visibleResponsibilities.length + + return ( + + {showShortDescription && ( + + {agent.shortDescription} + + )} + + {/* Zweck, Input, Kernablauf und Output — dieselbe Informationsbox wie in + der Teamübersicht, damit derselbe Sachverhalt nicht zweimal gebaut + wird. Sie bringt eigene Aussenabstände mit; der Reiter liegt bereits in + einer Fläche mit demselben Innenabstand, deshalb wird der doppelte + Einzug hier ausgeglichen statt die geteilte Komponente zu ändern. */} + + + + + + Kennzahlen + + {agent.metrics.map((metric) => ( + + ))} + + + + + Zuständigkeiten + + {visibleResponsibilities.map((text) => ( + + ))} + + {hiddenResponsibilities > 0 && ( + + {hiddenResponsibilities} weitere von insgesamt {agent.responsibilities.length} Zuständigkeiten + + )} + + + ) +} diff --git a/src/components/team/AgentDossier.tsx b/src/components/team/AgentDossier.tsx new file mode 100644 index 0000000..cc68199 --- /dev/null +++ b/src/components/team/AgentDossier.tsx @@ -0,0 +1,118 @@ +/** + * Property On — Personaldossier eines digitalen Mitarbeiters. + * + * Die Reihenfolge ist der eigentliche Inhalt dieser Komponente: zuerst die + * Stammdaten im Kopfbereich, unmittelbar darunter die Reiterleiste, erst dann + * der Inhalt des gewählten Reiters. Die Informationsbox stand bisher zwischen + * Kopf und Reitern und schob die Navigation nach unten aus dem Blickfeld — ihr + * Inhalt gehört in den ersten Reiter «Agentenbeschrieb», nicht in die + * Navigation. + * + * Der aktive Reiter wird nicht hier gehalten, sondern von aussen gesetzt: er + * steht in der Adresse, damit ein Dossier verlinkbar bleibt. + */ + +import { useCallback } from 'react' +import type { SyntheticEvent } from 'react' +import { Box, Tab, Tabs } from '@mui/material' +import type { TeamAgent } from '../../domain/teamAgent' +import { AgentDossierHeader } from './AgentDossierHeader' +import { AgentDescriptionTab } from './AgentDescriptionTab' +import { AgentTasksTab } from './AgentTasksTab' +import { AgentChannelsTab } from './AgentChannelsTab' +import { AgentSystemsTab } from './AgentSystemsTab' +import { AgentSettingsTab } from './AgentSettingsTab' +import { AgentProtocolTab } from './AgentProtocolTab' +import { DS_BG, DS_BORDER } from '../../lib/ds' + +/** + * URL-Segment je Reiter — bestehende Deep-Links müssen erhalten bleiben. + * + * Reiterliste und Segmentprüfung gehören zum Dossier und stehen deshalb hier, + * nicht in einer eigenen Datei. Der Preis dafür ist, dass diese Datei beim + * Bearbeiten neu geladen statt heiss ersetzt wird — das betrifft allein die + * Entwicklung, nicht die Anwendung. + */ +// eslint-disable-next-line react-refresh/only-export-components -- siehe oben +export const DOSSIER_TABS = [ + { segment: 'beschrieb', label: 'Agentenbeschrieb' }, + { segment: 'aufgaben', label: 'Aufgaben' }, + { segment: 'kanaele', label: 'Kanäle' }, + { segment: 'systeme', label: 'Systeme' }, + { segment: 'einstellungen', label: 'Einstellungen' }, + { segment: 'protokoll', label: 'Protokoll' }, +] as const + +export type DossierSegment = typeof DOSSIER_TABS[number]['segment'] + +export const DEFAULT_DOSSIER_SEGMENT: DossierSegment = 'beschrieb' + +// eslint-disable-next-line react-refresh/only-export-components -- siehe DOSSIER_TABS +export function isDossierSegment(value: string | undefined): value is DossierSegment { + return DOSSIER_TABS.some(tab => tab.segment === value) +} + +/** Verbindet Reiter und Inhaltsfläche für Screenreader. */ +const tabId = (segment: DossierSegment) => `agent-dossier-tab-${segment}` +const panelId = (segment: DossierSegment) => `agent-dossier-panel-${segment}` + +interface Props { + agent: TeamAgent + activeSegment: DossierSegment + onSegmentChange: (segment: DossierSegment) => void +} + +export function AgentDossier({ agent, activeSegment, onSegmentChange }: Props) { + const handleChange = useCallback( + (_event: SyntheticEvent, value: DossierSegment) => onSegmentChange(value), + [onSegmentChange], + ) + + return ( + <> + + + {/* Reiterleiste direkt unter den Stammdaten, auf derselben Fläche — sie + gehört zum Kopf des Dossiers und nicht zum Inhalt darunter. */} + + + {DOSSIER_TABS.map((tab) => ( + + ))} + + + + {/* `key` auf die Mitarbeiter-ID: beim Wechsel des Dossiers werden die + Reiter neu aufgebaut. Das ersetzt Zurücksetz-Effekte in jedem einzelnen + Reiter — sonst stünde der unbestätigte Entwurf des zuvor gewählten + Mitarbeiters in einem fremden Personalblatt. */} + + {activeSegment === 'beschrieb' && } + {activeSegment === 'aufgaben' && } + {activeSegment === 'kanaele' && } + {activeSegment === 'systeme' && } + {activeSegment === 'einstellungen' && } + {activeSegment === 'protokoll' && } + + + ) +} diff --git a/src/components/team/AgentDossierHeader.tsx b/src/components/team/AgentDossierHeader.tsx index 12f58d7..63b6d3e 100644 --- a/src/components/team/AgentDossierHeader.tsx +++ b/src/components/team/AgentDossierHeader.tsx @@ -48,7 +48,7 @@ export function AgentDossierHeader({ agent }: Props) { return ( - + diff --git a/src/components/team/AgentListPanel.tsx b/src/components/team/AgentListPanel.tsx index fc95869..3d29d44 100644 --- a/src/components/team/AgentListPanel.tsx +++ b/src/components/team/AgentListPanel.tsx @@ -35,7 +35,7 @@ const AgentListRow = memo(function AgentListRow({ agent, selected, onSelect }: R '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: -2 }, }} > - + {agent.name} diff --git a/src/components/team/AgentPreviewPopover.tsx b/src/components/team/AgentPreviewPopover.tsx new file mode 100644 index 0000000..7a58436 --- /dev/null +++ b/src/components/team/AgentPreviewPopover.tsx @@ -0,0 +1,61 @@ +import { Box, Popover, Typography } from '@mui/material' +import type { AgentDirectoryEntry } from '../../domain/agentDirectory' +import { AgentAvatar } from './AgentAvatar' +import { GenericBadge } from '../shared/GenericBadge' +import { AGENT_LEVEL_LABELS, AGENT_ROLLOUT_LABELS } from '../../lib/constants' +import { DS_TEXT } from '../../lib/ds' + +interface Props { + agent: AgentDirectoryEntry | null + anchorEl: HTMLElement | null + onClose: () => void +} + +/** + * Kompakte Vorschau für Mitarbeitende ausserhalb des Kernteams. + * + * Bewusst kein Dossier: nur die sieben Kernteammitglieder besitzen ein + * gepflegtes Personalblatt. Für die übrigen 29 zeigt die Vorschau ausschliesslich + * das, was im Verzeichnis steht — Name, Funktion, Bereich, Aufbaustand. Es + * werden keine Aufgaben, Kanäle oder Tätigkeiten erfunden. + */ +export function AgentPreviewPopover({ agent, anchorEl, onClose }: Props) { + return ( + + {agent && ( + + + + + {agent.name} + + + {agent.role} + + + {AGENT_LEVEL_LABELS[agent.level] ?? agent.level} + + + + + + + + Für diese Stufe ist noch kein Personaldossier hinterlegt. + + + + )} + + ) +} diff --git a/src/components/team/AgentRing.tsx b/src/components/team/AgentRing.tsx new file mode 100644 index 0000000..52d1a3d --- /dev/null +++ b/src/components/team/AgentRing.tsx @@ -0,0 +1,93 @@ +import { memo, useMemo } from 'react' +import { Box } from '@mui/material' +import type { AgentDirectoryEntry } from '../../domain/agentDirectory' +import type { ArcOptions, RingSpec } from './agentRingConfig' +import { layoutArc } from './agentRingConfig' +import { AgentRingNode } from './AgentRingNode' +import { DS_BORDER } from '../../lib/ds' + +interface Props { + agents: AgentDirectoryEntry[] + spec: RingSpec + scale: number + arc?: ArcOptions + selectedId?: string | null + onSelect: (agent: AgentDirectoryEntry) => void + /** Führungslinie hinter den Portraits — die Referenzgrafik zeigt sie. */ + showGuide?: boolean + visible?: boolean +} + +/** + * Ein Ring der Kreisdarstellung. + * + * Rechnet selbst keine Positionen aus, sondern bezieht sie aus `layoutArc` — + * so teilen sich der volle Kreis der Teamübersicht und der Halbkreis des + * Organigramms dieselbe Geometrie. + */ +export const AgentRing = memo(function AgentRing({ + agents, + spec, + scale, + arc, + selectedId, + onSelect, + showGuide = true, + visible = true, +}: Props) { + const positions = useMemo( + () => layoutArc(agents.length, spec.radius, arc), + [agents.length, spec.radius, arc], + ) + + return ( + + {showGuide && ( + + )} + + {agents.map((agent, index) => { + const position = positions[index] + if (!position) return null + return ( + + ) + })} + + ) +}) diff --git a/src/components/team/AgentRingNode.tsx b/src/components/team/AgentRingNode.tsx new file mode 100644 index 0000000..d3e7685 --- /dev/null +++ b/src/components/team/AgentRingNode.tsx @@ -0,0 +1,128 @@ +import { memo, useState } from 'react' +import { Box, Tooltip, Typography } from '@mui/material' +import type { AgentDirectoryEntry } from '../../domain/agentDirectory' +import type { RingSpec } from './agentRingConfig' +import { AgentAvatar } from './AgentAvatar' +import { BADGE_COLORS, DS_BORDER, DS_TEXT } from '../../lib/ds' + +interface Props { + agent: AgentDirectoryEntry + spec: RingSpec + /** Skalierungsfaktor gegenüber der Referenzbreite. */ + scale: number + leftPct: number + topPct: number + selected?: boolean + onSelect: (agent: AgentDirectoryEntry) => void +} + +const MIN_AVATAR_PX = 30 + +/** + * Ein Portrait auf dem Ring. + * + * Die Funktionsbezeichnung steht dauerhaft nur beim Kernteam; auf den äusseren + * Ringen erscheint sie bei Hover oder Tastaturfokus. Alles dauerhaft zu + * beschriften liesse die Beschriftungen überlappen — die Referenzgrafik löst es + * genauso. + * + * Der Tooltip trägt Name und Funktion immer, damit die Information nie + * ausschliesslich im Bild steckt. + */ +export const AgentRingNode = memo(function AgentRingNode({ + agent, + spec, + scale, + leftPct, + topPct, + selected = false, + onSelect, +}: Props) { + const [active, setActive] = useState(false) + const px = Math.max(MIN_AVATAR_PX, Math.round(spec.avatarPx * scale)) + const showRole = spec.showRole || active || selected + const labelWidth = Math.round(px * 2.1) + + return ( + onSelect(agent)} + onMouseEnter={() => setActive(true)} + onMouseLeave={() => setActive(false)} + onFocus={() => setActive(true)} + onBlur={() => setActive(false)} + sx={{ + position: 'absolute', + left: `${leftPct}%`, + top: `${topPct}%`, + transform: 'translate(-50%, -50%)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.375, + width: labelWidth, + p: 0, + border: 'none', + background: 'none', + font: 'inherit', + cursor: 'pointer', + zIndex: active || selected ? 3 : 1, + transition: 'transform 0.2s ease', + '&:hover, &:focus-visible': { transform: 'translate(-50%, -50%) scale(1.06)' }, + '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 4, borderRadius: 2 }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + '&:hover, &:focus-visible': { transform: 'translate(-50%, -50%)' }, + }, + }} + > + + + + + + + + {agent.name} + + + {/* Höhe wird reserviert, damit die Ringe beim Hover nicht springen */} + + {agent.role} + + + ) +}) diff --git a/src/components/team/ConnectionCard.tsx b/src/components/team/ConnectionCard.tsx index d750384..c33579a 100644 --- a/src/components/team/ConnectionCard.tsx +++ b/src/components/team/ConnectionCard.tsx @@ -1,20 +1,104 @@ -import { memo } from 'react' -import { Box, Button, Typography } from '@mui/material' -import { Plug, PlugZap, Unplug, Users } from 'lucide-react' -import type { AgentConnection } from '../../domain/agentConnection' -import type { TeamAgent } from '../../domain/teamAgent' -import { AgentConnectionStatus } from '../../domain/teamAgent' -import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges' +/** + * Property On — Karte einer zentralen Verbindung (§13). + * + * Verbunden oder nicht verbunden ist ein Zweizustand, also trägt ihn ein Regler + * und keine Knopfreihe: einschalten öffnet den Verbindungsassistenten, + * ausschalten löst die Trennung aus, die der Aufrufer weiterhin bestätigen + * lässt. «Verbindung testen» bleibt ein Knopf — das ist keine Zustandsänderung. + * + * Markenlogos gibt es im Projekt nicht und werden auch nicht nachgezeichnet. + * Stattdessen trägt jede Kategorie ein neutrales, einheitliches Sinnbild. + */ + +import { memo, useCallback, useMemo } from 'react' +import { Box, Button, Switch, Typography } from '@mui/material' import { - AGENT_CONNECTION_CATEGORY_LABELS, - AGENT_ERP_VENDOR_LABELS, -} from '../../lib/constants' + Calendar, + Contact, + Database, + FolderOpen, + Globe, + Mail, + MessageCircle, + Phone, + Users, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import type { AgentConnection } from '../../domain/agentConnection' +import { AgentConnectionCategory } from '../../domain/agentConnection' +import { AgentConnectionStatus } from '../../domain/teamAgent' +import { AgentConnectionStatusBadge } from './AgentBadges' +import { AgentAvatarGroup } from './AgentAvatarGroup' +import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants' import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' -import { formatTeamDateTime } from '../../lib/teamClock' +import { formatTeamRelative } from '../../lib/teamClock' + +const CATEGORY_ICON: Record = { + [AgentConnectionCategory.EMAIL_M365]: Mail, + [AgentConnectionCategory.WHATSAPP]: MessageCircle, + [AgentConnectionCategory.TEAMS]: Users, + [AgentConnectionCategory.CALENDAR]: Calendar, + [AgentConnectionCategory.DOCUMENT_STORE]: FolderOpen, + [AgentConnectionCategory.PHONE_VOICE]: Phone, + [AgentConnectionCategory.ERP]: Database, + [AgentConnectionCategory.CRM]: Contact, + [AgentConnectionCategory.PUBLIC_SOURCES]: Globe, +} + +/** Abkürzungen wie «St.» oder «z. B.» beenden keinen Satz — kurze Wörter überspringen. */ +const ABBREVIATION_MAX_LENGTH = 3 + +/** + * Erster Satz eines Beschriebs. Die Daten selbst bleiben unangetastet; nur die + * Karte zeigt weniger, damit alle Karten gleich ruhig wirken. + */ +function firstSentence(text: string): string { + const trimmed = text.trim() + for (let i = 0; i < trimmed.length; i += 1) { + const char = trimmed[i] + if (char !== '.' && char !== '!' && char !== '?') continue + const next = trimmed[i + 1] + if (next !== undefined && next !== ' ' && next !== '\n') continue + const head = trimmed.slice(0, i) + const word = head.slice(head.lastIndexOf(' ') + 1) + if (word.length <= ABBREVIATION_MAX_LENGTH) continue + return trimmed.slice(0, i + 1) + } + return trimmed +} + +/** + * Sinnbild der Kategorie in ruhiger Fläche. Auch von der Kurzliste auf der + * Teamübersicht genutzt, damit beide Ansichten dasselbe Zeichen führen. + */ +export const ConnectionCategoryIcon = memo(function ConnectionCategoryIcon({ + category, + size = 28, +}: { + category: AgentConnectionCategory + size?: number +}) { + const Icon = CATEGORY_ICON[category] + const label = AGENT_CONNECTION_CATEGORY_LABELS[category] ?? category + + return ( + + + + ) +}) interface Props { connection: AgentConnection - agents: TeamAgent[] busy: boolean onConfigure: (connectionId: string) => void onDisconnect: (connectionId: string) => void @@ -23,7 +107,6 @@ interface Props { export const ConnectionCard = memo(function ConnectionCard({ connection, - agents, busy, onConfigure, onDisconnect, @@ -31,10 +114,20 @@ export const ConnectionCard = memo(function ConnectionCard({ }: Props) { const isConnected = connection.status === AgentConnectionStatus.CONNECTED const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP - const users = connection.usedByAgentIds - .map(id => agents.find(a => a.id === id)?.name ?? id) - .join(', ') - const grantedPermissions = connection.permissions.filter(p => p.granted) + const purpose = useMemo(() => firstSentence(connection.description), [connection.description]) + + const handleToggle = useCallback(() => { + if (isConnected) onDisconnect(connection.id) + else onConfigure(connection.id) + }, [isConnected, onDisconnect, onConfigure, connection.id]) + + const handleDetails = useCallback(() => onConfigure(connection.id), [onConfigure, connection.id]) + const handleTest = useCallback(() => onTest(connection.id), [onTest, connection.id]) + + // Neun Regler auf einer Seite: der Name muss im Bedienhilfe-Text mitkommen. + const switchLabel = isRoadmap + ? `${connection.name}: geplant, noch nicht verfügbar` + : `${connection.name} ${isConnected ? 'trennen' : 'verbinden'}` return ( - - - - {connection.name} - - - {AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.category} - {connection.vendor ? ` · ${AGENT_ERP_VENDOR_LABELS[connection.vendor]}` : ''} - - - + + + + + {connection.name} + + + - - {connection.description} + + {purpose} - {connection.connectionLabel && ( - - Verbindung: «{connection.connectionLabel}» - - )} - - - - {connection.accountCount === 1 - ? '1 verbundenes Konto' - : `${connection.accountCount} verbundene Konten`} - - - - - - {users || 'Noch keinem Mitarbeiter zugewiesen'} - - - - - {connection.lastSyncAt - ? `Letzte Synchronisation ${formatTeamDateTime(connection.lastSyncAt)}` - : 'Noch keine Synchronisation'} - + + + - {grantedPermissions.length > 0 && ( - - {grantedPermissions.map((permission) => ( - - ))} - - )} - - - + + + {connection.lastSyncAt + ? `Synchronisiert ${formatTeamRelative(connection.lastSyncAt)}` + : 'Noch keine Synchronisation'} + {isConnected && ( - <> + - - )} - - {isRoadmap && ( - - Diese Anbindung ist geplant. - + )} diff --git a/src/components/team/ConnectionSummaryList.tsx b/src/components/team/ConnectionSummaryList.tsx index 3c9032e..9057c1a 100644 --- a/src/components/team/ConnectionSummaryList.tsx +++ b/src/components/team/ConnectionSummaryList.tsx @@ -1,66 +1,95 @@ +import { memo } from 'react' import { Box, Typography } from '@mui/material' import type { AgentConnection } from '../../domain/agentConnection' import type { TeamAgent } from '../../domain/teamAgent' import { AgentConnectionStatusBadge } from './AgentBadges' +import { AgentAvatarGroup } from './AgentAvatarGroup' +import { ConnectionCategoryIcon } from './ConnectionCard' import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants' import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' import { formatTeamRelative } from '../../lib/teamClock' interface Props { connections: AgentConnection[] - agents: TeamAgent[] + /** + * Wird nicht mehr benötigt: die Porträtgruppe löst die Namen selbst aus dem + * Verzeichnis auf. Bleibt optional erhalten, damit bestehende Aufrufer + * unverändert weiterlaufen. + */ + agents?: TeamAgent[] } /** * Kompakte Darstellung der Verbindungen auf der Teamübersicht (§6.6). - * Dieselben fachlichen Inhalte wie auf der vollständigen Seite «Kanäle & Systeme», - * nur ohne Aktionen — verbunden wird dort, nicht hier. + * Dasselbe Kartenmuster wie auf «Kanäle & Systeme» — Sinnbild, Name, Zustand, + * Porträtgruppe —, nur ohne Aktionen: verbunden wird dort, nicht hier. */ -export function ConnectionSummaryList({ connections, agents }: Props) { - const nameById = new Map(agents.map(a => [a.id, a.name])) - +export function ConnectionSummaryList({ connections }: Props) { return ( - {connections.map((connection) => { - const users = connection.usedByAgentIds.map(id => nameById.get(id) ?? id) - return ( - - - - {AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name} - - - - - - {users.length > 0 ? `Genutzt von ${users.join(', ')}` : 'Noch keinem Mitarbeiter zugewiesen'} - - - - {connection.lastSyncAt - ? `Zuletzt abgeglichen ${formatTeamRelative(connection.lastSyncAt)}` - : 'Noch kein Abgleich'} - - - ) - })} + {connections.map((connection) => ( + + ))} ) } + +const ConnectionSummaryCard = memo(function ConnectionSummaryCard({ + connection, +}: { + connection: AgentConnection +}) { + const title = AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name + + return ( + + + + + {title} + + + + + + + + {connection.lastSyncAt + ? `Zuletzt abgeglichen ${formatTeamRelative(connection.lastSyncAt)}` + : 'Noch kein Abgleich'} + + + ) +}) diff --git a/src/components/team/ConnectionWizardSteps.tsx b/src/components/team/ConnectionWizardSteps.tsx index 96bab4c..f986609 100644 --- a/src/components/team/ConnectionWizardSteps.tsx +++ b/src/components/team/ConnectionWizardSteps.tsx @@ -189,7 +189,7 @@ export function StepAgents({ agents, draft, onChange }: StepProps) { onChange={() => toggle(agent.id)} slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }} /> - + {agent.name} diff --git a/src/components/team/RotatableAgentWheel.tsx b/src/components/team/RotatableAgentWheel.tsx new file mode 100644 index 0000000..19a16ec --- /dev/null +++ b/src/components/team/RotatableAgentWheel.tsx @@ -0,0 +1,209 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { Box, Typography } from '@mui/material' +import type { AgentDirectoryEntry } from '../../domain/agentDirectory' +import { AgentAvatar } from './AgentAvatar' +import { BADGE_COLORS, DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' + +interface Props { + agents: AgentDirectoryEntry[] + selectedId: string | null + onSelect: (agentId: string) => void +} + +/** Grad Drehung je gezogenem Pixel. Ein voller Durchlauf braucht so gut 700 px. */ +const DRAG_DEGREES_PER_PX = 0.5 +/** Durchmesser des gedachten Vollkreises; sichtbar ist nur die untere Hälfte. */ +const WHEEL_DIAMETER = 720 +const AVATAR_PX = 66 + +/** + * Drehbares Agentenrad der Organigramm-Ansicht. + * + * Der gedachte Vollkreis ragt nach oben aus dem Container heraus und wird von + * ihm abgeschnitten — sichtbar bleibt der untere Halbkreis. Deshalb wird nichts + * am Bild zugeschnitten, sondern allein per `overflow: hidden` beschnitten. + * + * Aktiv ist immer, wer unten in der Mitte steht. Während des Ziehens läuft nur + * ein lokaler Drehwert mit; die Auswahl wechselt erst beim Einrasten. Sonst + * würde bei jeder Mausbewegung das gesamte Dossier darunter neu aufgebaut. + */ +export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) { + const step = agents.length > 0 ? 360 / agents.length : 360 + const selectedIndex = Math.max(0, agents.findIndex(a => a.id === selectedId)) + + const [dragDeg, setDragDeg] = useState(0) + // Zwei Grössen für denselben Vorgang: die Ref trägt den Startpunkt durch die + // Pointer-Ereignisse, der State steuert die Darstellung. Eine Ref im Render zu + // lesen wäre regelwidrig — die Änderung löst kein neues Rendern aus. + const [isDragging, setIsDragging] = useState(false) + const dragging = useRef<{ startX: number } | null>(null) + + // Drehung, die den gewählten Agenten nach unten in die Mitte bringt. + const baseDeg = 180 - selectedIndex * step + const rotation = baseDeg + dragDeg + + const positions = useMemo( + () => agents.map((_, i) => i * step), + [agents, step], + ) + + const commitNearest = useCallback( + (currentRotation: number) => { + if (agents.length === 0) return + // Welcher Index steht dieser Drehung am nächsten? + const raw = (180 - currentRotation) / step + const index = ((Math.round(raw) % agents.length) + agents.length) % agents.length + const next = agents[index] + if (next && next.id !== selectedId) onSelect(next.id) + setDragDeg(0) + }, + [agents, step, selectedId, onSelect], + ) + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + dragging.current = { startX: e.clientX } + setIsDragging(true) + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!dragging.current) return + setDragDeg((e.clientX - dragging.current.startX) * DRAG_DEGREES_PER_PX) + }, []) + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!dragging.current) return + const offset = (e.clientX - dragging.current.startX) * DRAG_DEGREES_PER_PX + dragging.current = null + setIsDragging(false) + commitNearest(baseDeg + offset) + }, + [baseDeg, commitNearest], + ) + + const move = useCallback( + (delta: number) => { + if (agents.length === 0) return + const next = agents[(selectedIndex + delta + agents.length) % agents.length] + if (next) onSelect(next.id) + }, + [agents, selectedIndex, onSelect], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault() + move(1) + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault() + move(-1) + } + }, + [move], + ) + + const active = agents[selectedIndex] + + return ( + + + {agents.map((agent, index) => { + const angle = positions[index] + const isActive = index === selectedIndex + return ( + onSelect(agent.id)} + sx={{ + position: 'absolute', + left: '50%', + top: '50%', + // erst auf den Radius schieben, dann die Raddrehung ausgleichen, + // damit die Portraits aufrecht stehen + transform: `rotate(${angle}deg) translateY(${WHEEL_DIAMETER / 2 - AVATAR_PX}px) rotate(${-angle - rotation}deg) translate(-50%, -50%)`, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.5, + width: 150, + cursor: 'pointer', + }} + > + + + {agent.name} + + {isActive && ( + + {agent.role} + + )} + + ) + })} + + + + Ziehen oder Pfeiltasten, um zu wechseln — {agents.length} Mitarbeitende + + + ) +} diff --git a/src/components/team/TeamLevelTabs.tsx b/src/components/team/TeamLevelTabs.tsx new file mode 100644 index 0000000..95ec22b --- /dev/null +++ b/src/components/team/TeamLevelTabs.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react' +import { Box, Tab, Tabs, Typography } from '@mui/material' +import type { AgentLevel } from '../../domain/agentDirectory' +import { AGENT_LEVEL_ORDER } from '../../domain/agentDirectory' +import { agentDirectory } from '../../mock-data/agentDirectory' +import { AGENT_LEVEL_TAB_LABELS } from '../../lib/constants' +import { DS_TEXT } from '../../lib/ds' + +interface Props { + value: AgentLevel + onChange: (level: AgentLevel) => void +} + +/** + * Filterreiter über der Kreisdarstellung. + * + * Die Stufen sind kumulativ: «+ Zentrale Dienste» zeigt Kernteam UND Zentrale + * Dienste. Deshalb steht neben jedem Reiter die Gesamtzahl der dann sichtbaren + * Mitarbeitenden und nicht die Zahl der Stufe allein — sonst läse sich «+ GB + * Kommerziell · 7» so, als verschwänden die inneren Ringe. + * + * Bezeichnungen stammen wörtlich aus dem Konzeptdokument. Keine neuen + * Stufennamen erfunden. + */ +export function TeamLevelTabs({ value, onChange }: Props) { + const cumulativeCounts = useMemo(() => { + const counts = new Map() + let running = 0 + for (const level of AGENT_LEVEL_ORDER) { + running += agentDirectory.filter(a => a.level === level).length + counts.set(level, running) + } + return counts + }, []) + + return ( + onChange(next)} + variant="scrollable" + scrollButtons="auto" + allowScrollButtonsMobile + aria-label="Personalstufen" + sx={{ + minHeight: 40, + '& .MuiTab-root': { + textTransform: 'none', + fontWeight: 600, + fontSize: '0.875rem', + minHeight: 40, + py: 0.5, + }, + }} + > + {AGENT_LEVEL_ORDER.map((level) => ( + + {AGENT_LEVEL_TAB_LABELS[level] ?? level} + + {cumulativeCounts.get(level)} + + + } + /> + ))} + + ) +} diff --git a/src/components/team/TeamOverviewRing.tsx b/src/components/team/TeamOverviewRing.tsx new file mode 100644 index 0000000..c7d45df --- /dev/null +++ b/src/components/team/TeamOverviewRing.tsx @@ -0,0 +1,174 @@ +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, number] { + const ref = useRef(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(null) + const [anchorEl, setAnchorEl] = useState(null) + + const scale = width / RING_REFERENCE_PX + const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level) + + const agentsByLevel = useMemo(() => { + const map = new Map() + 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 ( + <> + + {RING_SPECS.map((spec, index) => ( + + ))} + + {/* Nabe */} + + + PROPERTY{' '} + ON + + + {agentDirectory.length} + + + digitale Mitarbeitende + + + + + + + ) +} diff --git a/src/components/team/WorkItemCard.tsx b/src/components/team/WorkItemCard.tsx index a1ed12d..b120d9c 100644 --- a/src/components/team/WorkItemCard.tsx +++ b/src/components/team/WorkItemCard.tsx @@ -1,15 +1,21 @@ -import { memo } from 'react' -import { Box, Typography } from '@mui/material' -import { AlertTriangle, Building2, Radio } from 'lucide-react' -import type { AgentWorkItem } from '../../domain/agentWorkItem' +import { memo, useMemo } from 'react' +import type { ReactNode } from 'react' +import { Box, Tooltip, Typography } from '@mui/material' +import { AlertTriangle, Building2, HelpCircle, Radio } from 'lucide-react' +import type { AgentWorkItem, AgentWorkItemPriority } from '../../domain/agentWorkItem' +import { + AgentWorkItemPriority as Priority, + AgentWorkItemStatus as WorkItemStatus, +} from '../../domain/agentWorkItem' import type { TeamAgent } from '../../domain/teamAgent' import { AgentAvatar } from './AgentAvatar' -import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges' +import { AgentWorkItemStatusBadge } from './AgentBadges' import { AGENT_CHANNEL_LABELS, + AGENT_PRIORITY_LABELS, AGENT_WORK_ITEM_KIND_LABELS, } from '../../lib/constants' -import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds' +import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' import { formatTeamDateTime, formatTeamRelative } from '../../lib/teamClock' interface Props { @@ -19,17 +25,67 @@ interface Props { onSelect: (id: string) => void } +const AVATAR_PX = 44 + +/** + * Nur die beiden Ausnahmestufen tragen überhaupt eine Farbe. «Mittel» und + * «Niedrig» sind der Normalfall und werden gar nicht angezeigt — eine Angabe, + * die auf fast jeder Karte steht, trägt keine Information. + */ +const PRIORITY_ACCENT: Partial> = { + [Priority.CRITICAL]: DS_TEXT.error, + [Priority.HIGH]: DS_TEXT.warning, +} + +/** Dezentes Trennzeichen statt eingefärbter Flächen zwischen den Fussangaben. */ +function MetaDot() { + return ( + + · + + ) +} + +function MetaEntry({ icon, text }: { icon?: ReactNode; text: string }) { + return ( + + {icon} + + {text} + + + ) +} + /** * Ein Eintrag im Bearbeitungsverlauf. * * `React.memo` ist hier nicht optional: die Liste rendert bis zu 14 Karten und * der Drawer verändert bei jeder Auswahl den Zustand der Seite — ohne Memo * würde jede Auswahl die gesamte Liste neu zeichnen (CLAUDE.md §10.2). + * + * Farbführung: die Karte bleibt grundsätzlich neutral. Farbe erscheint nur + * dort, wo sie eine Abweichung meldet — Ausnahmepriorität, ein vom + * Erwartungswert abweichender Status und der ausgewählte Zustand. */ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, onSelect }: Props) { const kindLabel = AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind const channelLabel = AGENT_CHANNEL_LABELS[item.sourceChannel] ?? item.sourceChannel const timestamp = item.completedAt ?? item.createdAt + const priorityAccent = PRIORITY_ACCENT[item.priority] + + // Auch ohne Objekt-ID anzeigen: Vorgänge ohne Objektbezug tragen im Label oft + // die entscheidende Einordnung («Zuordnung offen — Region Zug»). Hinge der + // Block allein an der ID, ginge sie verloren. + const objectText = useMemo( + () => [item.objectId, item.objectLabel].filter(Boolean).join(' · '), + [item.objectId, item.objectLabel], + ) + + // Erwartungswert: pendente Vorgänge stehen auf «Offen», erledigte auf + // «Abgeschlossen». Nur eine Abweichung davon ist eine Meldung wert. + const expectedStatus = item.requiresDecision ? WorkItemStatus.PENDING : WorkItemStatus.COMPLETED + const showStatus = item.status !== expectedStatus return ( - - {agent && } + + {agent && ( + + + {agent.name} + + + {agent.role} + + + } + > + {/* Wrapper, weil MUI dem Kind eine Referenz anhängt — `AgentAvatar` + ist memoisiert und nimmt selbst keine entgegen. */} + + + + + )} {/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */} - + {agent?.name ?? 'Unbekannt'} @@ -73,62 +150,49 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, - + {item.title} - + {item.summary} - {/* Grund der Rückfrage — der wichtigste Satz bei pendenten Vorgängen */} + {/* Grund der Rückfrage — der wichtigste Satz bei pendenten Vorgängen. + Bewusst als schlichter Satz mit Icon, nicht als eingefärbte Fläche. */} {item.requiresDecision && item.escalationReason && ( - - - + + + {item.escalationReason} )} - {/* Fusszeile: Vorgangstyp, Objektbezug, Quellkanal, Status */} - - - - - - {kindLabel} - - - {/* Auch ohne Objekt-ID anzeigen: Vorgänge ohne Objektbezug tragen im - Label oft die entscheidende Einordnung («Zuordnung offen — Region - Zug»). Hinge der Block allein an der ID, ginge sie verloren. */} - {(item.objectId || item.objectLabel) && ( - - - - {[item.objectId, item.objectLabel].filter(Boolean).join(' · ')} + {/* Fusszeile: Ausnahmen zuerst, danach Vorgangstyp, Objektbezug, Kanal */} + + {priorityAccent && ( + + + + {AGENT_PRIORITY_LABELS[item.priority] ?? item.priority} )} - - - - {channelLabel} - - + {showStatus && } + + + + {objectText && ( + <> + + } text={objectText} /> + + )} + + + } text={channelLabel} /> {formatTeamRelative(timestamp)} diff --git a/src/components/team/WorkItemDetailDrawer.tsx b/src/components/team/WorkItemDetailDrawer.tsx index 8cb631e..6ff6759 100644 --- a/src/components/team/WorkItemDetailDrawer.tsx +++ b/src/components/team/WorkItemDetailDrawer.tsx @@ -58,7 +58,7 @@ export function WorkItemDetailDrawer() { flexShrink: 0, }} > - {agent && } + {agent && } {agent ? `${agent.name} · ${agent.role}` : 'Vorgang'} diff --git a/src/components/team/WorkItemList.tsx b/src/components/team/WorkItemList.tsx index 1ee5404..cc31c87 100644 --- a/src/components/team/WorkItemList.tsx +++ b/src/components/team/WorkItemList.tsx @@ -14,6 +14,12 @@ interface Props { emptyDescription: string } +/** + * Abstand zwischen zwei Vorgängen. Bewusst grosszügig: die Karten tragen kaum + * noch Farbe, die Trennung übernimmt der Weissraum. + */ +const LIST_GAP = 2.5 + export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) { const { data: items = [], isLoading, isError, refetch } = useAgentWorkItems(filters) const { data: agents = [] } = useTeamAgents() @@ -25,7 +31,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) { if (isLoading) { return ( - + @@ -42,7 +48,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) { } return ( - + {items.map((item) => ( a.personnelNumber === personnelNumber) - if (!agent) throw new Error(`Kein Mitarbeiter mit Personalnummer ${personnelNumber} im Katalog`) - return agent -} - -function headlineMetricOf(agent: TeamAgent): AgentMetric { - const metric = agent.metrics.find(m => m.id === agent.headlineMetricId) - if (!metric) throw new Error(`Kurzkennzahl ${agent.headlineMetricId} fehlt bei ${agent.name}`) - return metric -} - -/** Testing Library normalisiert Whitespace im DOM — der Erwartungswert muss gleich behandelt werden. */ -function normalized(text: string): string { - return text.replace(/\s+/g, ' ').trim() -} - -const ferdi = agentByPersonnelNumber('PO-ZD-02') - -describe('AgentCard', () => { - it('rendert Name, Rolle und Personalnummer des Mitarbeiters', () => { - renderWithProviders() - - const card = screen.getByRole('article') - expect(within(card).getByText(ferdi.name)).toBeInTheDocument() - expect(within(card).getByText(ferdi.role)).toBeInTheDocument() - expect(within(card).getByText(`Personalnummer ${ferdi.personnelNumber}`)).toBeInTheDocument() - }) - - it('rendert die Kurzbeschreibung', () => { - renderWithProviders() - - expect(screen.getByText(normalized(ferdi.shortDescription))).toBeInTheDocument() - }) - - it('zeigt den Aktivstatus als Text «Aktiv», nicht nur als farbigen Punkt', () => { - expect(ferdi.status).toBe(AgentStatus.ACTIVE) - renderWithProviders() - - const statusText = screen.getByText('Aktiv') - expect(statusText).toBeInTheDocument() - expect(statusText.textContent).toBe('Aktiv') - }) - - it('zeigt Wert und Label der Kurzkennzahl aus headlineMetricId', () => { - const headline = headlineMetricOf(ferdi) - renderWithProviders() - - const card = screen.getByRole('article') - expect(within(card).getByText(normalized(headline.value))).toBeInTheDocument() - expect(within(card).getByText(normalized(headline.label))).toBeInTheDocument() - }) - - it('zeigt ausschliesslich die Kurzkennzahl, nicht den ganzen Kennzahlensatz', () => { - const headline = headlineMetricOf(ferdi) - const otherLabels = ferdi.metrics.filter(m => m.id !== headline.id).map(m => normalized(m.label)) - renderWithProviders() - - expect(otherLabels.length).toBeGreaterThan(0) - for (const label of otherLabels) { - expect(screen.queryByText(label)).not.toBeInTheDocument() - } - }) - - it('ruft onOpen mit der Agenten-ID auf, wenn «Personalblatt öffnen» geklickt wird', async () => { - const onOpen = vi.fn() - renderWithProviders() - - fireEvent.click(screen.getByRole('button', { name: /Personalblatt öffnen/i })) - - await waitFor(() => expect(onOpen).toHaveBeenCalledTimes(1), WAIT) - expect(onOpen).toHaveBeenCalledWith(ferdi.id) - }) - - it('ruft onOpen nicht auf, solange nichts geklickt wurde', () => { - const onOpen = vi.fn() - renderWithProviders() - - expect(onOpen).not.toHaveBeenCalled() - }) - - it('zeigt bei einem pausierten Mitarbeiter «Pausiert» statt «Aktiv»', () => { - const paused: TeamAgent = { ...ferdi, status: AgentStatus.PAUSED } - renderWithProviders() - - expect(screen.getByText('Pausiert')).toBeInTheDocument() - expect(screen.queryByText('Aktiv')).not.toBeInTheDocument() - }) - - it('rendert für jeden Mitarbeiter des Kernteams Name, Rolle und Personalnummer', () => { - expect(mockTeamAgents.length).toBeGreaterThan(0) - - for (const agent of mockTeamAgents) { - const { unmount } = renderWithProviders() - - expect(screen.getByText(agent.name)).toBeInTheDocument() - expect(screen.getByText(agent.role)).toBeInTheDocument() - expect(screen.getByText(`Personalnummer ${agent.personnelNumber}`)).toBeInTheDocument() - - unmount() - } - }) - - it('rendert die Zeitangabe zum letzten Lauf, ohne dass ein konkreter Abstand geprüft wird', () => { - renderWithProviders() - - const card = screen.getByRole('article') - expect(within(card).getByText(/Zuletzt aktiv|Noch nicht gelaufen/)).toBeInTheDocument() - }) - - it('rendert das Porträt des Mitarbeiters', () => { - renderWithProviders() - - const portrait = screen.getByRole('img', { name: `${ferdi.name}, ${ferdi.role}` }) - expect(portrait).toHaveAttribute('src') - expect(portrait.getAttribute('src')).toBeTruthy() - }) - - it('fällt ohne hinterlegtes Porträt auf die Initialen zurück', () => { - // Ein Mitarbeiter, zu dem in `agentPhotos.ts` kein Bild liegt — so verhält - // sich die Karte, falls der Katalog später um jemanden ohne Foto wächst. - const ohneFoto = { ...ferdi, id: 'unbekannt' } - renderWithProviders() - - const card = screen.getByRole('article') - expect(within(card).getByText(ferdi.name.slice(0, 2).toUpperCase())).toBeInTheDocument() - }) - - it('gibt dem Avatar einen zugänglichen Namen aus Name und Rolle', () => { - renderWithProviders() - - // Der Name hängt an genau einem Element: mit Porträt am über `alt`, - // ohne Porträt am Container über role="img" + aria-label (§18). - expect( - screen.getByRole('img', { name: `${ferdi.name}, ${ferdi.role}` }), - ).toBeInTheDocument() - - // Der Mitarbeiter bleibt auf der Karte trotzdem eindeutig identifizierbar: - // Name und Rolle stehen als sichtbarer Text daneben. - expect(screen.getByText(ferdi.name)).toBeInTheDocument() - expect(screen.getByText(ferdi.role)).toBeInTheDocument() - }) -}) diff --git a/src/components/team/__tests__/TeamOverviewRing.test.tsx b/src/components/team/__tests__/TeamOverviewRing.test.tsx new file mode 100644 index 0000000..f575fae --- /dev/null +++ b/src/components/team/__tests__/TeamOverviewRing.test.tsx @@ -0,0 +1,136 @@ +/** + * Property On — Kreisdarstellung der Belegschaft. + * + * Abgesichert werden die Zusicherungen aus den Abnahmekriterien: die richtigen + * sieben im Kernteam, kumulative Stufenfilter, Portraits mit zugänglichem Namen + * und — fachlich am wichtigsten — dass ausschliesslich Kernteammitglieder in ein + * Dossier führen. Für die übrigen 29 gibt es keines, also darf auch kein Klick + * dorthin führen. + */ + +import { describe, it, expect } from 'vitest' +import { useLocation } from 'react-router' +import { screen, fireEvent, within } from '@testing-library/react' +import { renderWithProviders } from '../../../test/teamTestUtils' +import { TeamOverviewRing } from '../TeamOverviewRing' +import { TeamLevelTabs } from '../TeamLevelTabs' +import { agentDirectory } from '../../../mock-data/agentDirectory' +import { AgentLevel } from '../../../domain/agentDirectory' +import { AGENT_LEVEL_TAB_LABELS } from '../../../lib/constants' + +function LocationProbe() { + const location = useLocation() + return
{location.pathname}
+} + +const core = agentDirectory.filter(a => a.isCore) +const nonCore = agentDirectory.filter(a => !a.isCore) + +describe('Kernteam', () => { + it('besteht aus genau sieben Mitarbeitenden', () => { + expect(core).toHaveLength(7) + }) + + it('enthält die sieben Namen der Referenzgrafik', () => { + expect(core.map(a => a.name).sort()).toEqual( + ['Bruno', 'Ferdi', 'Lea', 'Livia', 'Nora', 'Reto', 'Sina'], + ) + }) + + it('umfasst insgesamt 36 Mitarbeitende auf vier Stufen', () => { + expect(agentDirectory).toHaveLength(36) + expect(agentDirectory.filter(a => a.level === AgentLevel.CORE)).toHaveLength(7) + expect(agentDirectory.filter(a => a.level === AgentLevel.CENTRAL_SERVICES)).toHaveLength(18) + expect(agentDirectory.filter(a => a.level === AgentLevel.COMMERCIAL)).toHaveLength(7) + expect(agentDirectory.filter(a => a.level === AgentLevel.RESIDENTIAL)).toHaveLength(4) + }) +}) + +describe('TeamLevelTabs', () => { + it('zeigt die vier Stufen mit dem Wortlaut des Konzepts', () => { + renderWithProviders( {}} />) + for (const level of Object.values(AgentLevel)) { + expect(screen.getByText(AGENT_LEVEL_TAB_LABELS[level])).toBeInTheDocument() + } + }) + + it('beziffert die Stufen kumulativ, nicht je Stufe einzeln', () => { + renderWithProviders( {}} />) + const tabs = screen.getAllByRole('tab') + expect(within(tabs[0]).getByText('7')).toBeInTheDocument() + expect(within(tabs[1]).getByText('25')).toBeInTheDocument() + expect(within(tabs[2]).getByText('32')).toBeInTheDocument() + expect(within(tabs[3]).getByText('36')).toBeInTheDocument() + }) + + it('meldet die gewählte Stufe nach oben', () => { + const seen: string[] = [] + renderWithProviders( + seen.push(l)} />, + ) + fireEvent.click(screen.getByText(AGENT_LEVEL_TAB_LABELS[AgentLevel.COMMERCIAL])) + expect(seen).toEqual([AgentLevel.COMMERCIAL]) + }) +}) + +describe('TeamOverviewRing', () => { + it('gibt jedem Portrait einen zugänglichen Namen aus Name und Funktion', () => { + renderWithProviders() + + // Bewusst EINE Abfrage statt 36 einzelner `getByRole(..., { name })`: + // jede Namensabfrage berechnet die Accessible Names des gesamten Baums neu, + // was bei 36 Knoten in die Zeitüberschreitung läuft. + const labels = screen + .getAllByRole('button') + .map(node => node.getAttribute('aria-label')) + + for (const agent of agentDirectory) { + expect(labels).toContain(`${agent.name}, ${agent.role}`) + } + expect(labels).toHaveLength(agentDirectory.length) + }) + + it('zeigt die Gesamtzahl in der Nabe', () => { + renderWithProviders() + expect(screen.getByText(String(agentDirectory.length))).toBeInTheDocument() + expect(screen.getByText('digitale Mitarbeitende')).toBeInTheDocument() + }) + + it('führt beim Kernteam ins Personaldossier', () => { + renderWithProviders( + <> + + + , + ) + const ferdi = core.find(a => a.id === 'ferdi')! + fireEvent.click(screen.getByRole('button', { name: `${ferdi.name}, ${ferdi.role}` })) + expect(screen.getByTestId('location')).toHaveTextContent( + '/supply/team/personalverwaltung/ferdi', + ) + }) + + it('führt ausserhalb des Kernteams NICHT ins Dossier, sondern zeigt eine Vorschau', () => { + renderWithProviders( + <> + + + , + ) + const other = nonCore[0] + fireEvent.click(screen.getByRole('button', { name: `${other.name}, ${other.role}` })) + + expect(screen.getByTestId('location')).toHaveTextContent('/') + expect(screen.getByTestId('location')).not.toHaveTextContent('personalverwaltung') + expect( + screen.getByText('Für diese Stufe ist noch kein Personaldossier hinterlegt.'), + ).toBeInTheDocument() + }) + + it('blendet äussere Ringe aus, solange nur das Kernteam gewählt ist', () => { + const { container } = renderWithProviders() + const hidden = container.querySelectorAll('[aria-hidden="true"]') + // Drei der vier Ringe sind ausgeblendet; die Führungslinien zählen zusätzlich. + expect(hidden.length).toBeGreaterThanOrEqual(3) + }) +}) diff --git a/src/components/team/agentPhotos.ts b/src/components/team/agentPhotos.ts index b022c73..daafb86 100644 --- a/src/components/team/agentPhotos.ts +++ b/src/components/team/agentPhotos.ts @@ -1,17 +1,19 @@ /** - * Property On — Porträts der digitalen Mitarbeiter. + * Property On — Porträts aller digitalen Mitarbeitenden. * - * Die Bilder stammen aus dem Konzeptdokument «Agenten_Bewirtschaftung_v2.html», - * wo sie als eingebettete JPEGs am jeweiligen Agenten hängen. Sie sind damit - * Teil der fachlichen Vorgabe, nicht eine gestalterische Zutat: das digitale - * Personalbüro zeigt Mitarbeiter mit Gesicht, nicht abstrakte Werkzeuge. + * Die Bilder stammen aus dem Konzeptdokument, wo sie als eingebettete JPEGs am + * jeweiligen Agenten hängen. Sie sind fachliche Vorgabe, nicht Dekoration: das + * digitale Personalbüro zeigt Mitarbeitende mit Gesicht, keine abstrakten + * Werkzeuge. * - * Die Zuordnung liegt bewusst in der Darstellungsschicht und nicht in - * `domain/teamAgent.ts` oder den Mockdaten. Ein Domain-Typ, der Binärdateien - * importiert, wäre an den Bundler gekoppelt und liesse sich weder in einem Test - * noch später gegen ein echtes Backend sauber verwenden. + * Die Zuordnung liegt bewusst in der Darstellungsschicht und nicht im Domain-Typ + * oder in den Mockdaten. Ein Domain-Modell, das Binärdateien importiert, wäre an + * den Bundler gekoppelt und weder im Test noch später gegen ein echtes Backend + * sauber verwendbar. * * Fehlt zu einer ID ein Bild, fällt `AgentAvatar` auf die Initialen zurück. + * + * ACHTUNG: generiert. Nicht von Hand pflegen. */ import bruno from '../../assets/team/bruno.jpg' @@ -21,6 +23,35 @@ import livia from '../../assets/team/livia.jpg' import nora from '../../assets/team/nora.jpg' import reto from '../../assets/team/reto.jpg' import sina from '../../assets/team/sina.jpg' +import ada from '../../assets/team/ada.jpg' +import carla from '../../assets/team/carla.jpg' +import diego from '../../assets/team/diego.jpg' +import dora from '../../assets/team/dora.jpg' +import emil from '../../assets/team/emil.jpg' +import filip from '../../assets/team/filip.jpg' +import kira from '../../assets/team/kira.jpg' +import lars from '../../assets/team/lars.jpg' +import mia from '../../assets/team/mia.jpg' +import paul from '../../assets/team/paul.jpg' +import pia from '../../assets/team/pia.jpg' +import rea from '../../assets/team/rea.jpg' +import rico from '../../assets/team/rico.jpg' +import uli from '../../assets/team/uli.jpg' +import vela from '../../assets/team/vela.jpg' +import vera from '../../assets/team/vera.jpg' +import vinz from '../../assets/team/vinz.jpg' +import vito from '../../assets/team/vito.jpg' +import beat from '../../assets/team/beat.jpg' +import fabio from '../../assets/team/fabio.jpg' +import nadia from '../../assets/team/nadia.jpg' +import oskar from '../../assets/team/oskar.jpg' +import otto from '../../assets/team/otto.jpg' +import sven from '../../assets/team/sven.jpg' +import tino from '../../assets/team/tino.jpg' +import gian from '../../assets/team/gian.jpg' +import ida from '../../assets/team/ida.jpg' +import jana from '../../assets/team/jana.jpg' +import zeno from '../../assets/team/zeno.jpg' export const AGENT_PHOTOS: Record = { bruno, @@ -30,4 +61,33 @@ export const AGENT_PHOTOS: Record = { nora, reto, sina, + ada, + carla, + diego, + dora, + emil, + filip, + kira, + lars, + mia, + paul, + pia, + rea, + rico, + uli, + vela, + vera, + vinz, + vito, + beat, + fabio, + nadia, + oskar, + otto, + sven, + tino, + gian, + ida, + jana, + zeno, } diff --git a/src/components/team/agentRingConfig.ts b/src/components/team/agentRingConfig.ts new file mode 100644 index 0000000..8b87237 --- /dev/null +++ b/src/components/team/agentRingConfig.ts @@ -0,0 +1,103 @@ +/** + * Property On — Geometrie der Kreisdarstellung. + * + * Einziger Ort, an dem Radien, Winkel und Portraitgrössen stehen. Die + * Komponenten rechnen selbst nichts aus und tragen keine Positionswerte — + * verteilte Positionsdaten in mehreren Komponenten wären beim ersten + * Layoutwechsel nicht mehr konsistent zu halten. + * + * Alle Radien sind relativ zur halben Containerbreite (0…1), damit die + * Darstellung ohne Umrechnung mitskaliert. + */ + +import { AgentLevel } from '../../domain/agentDirectory' + +export interface RingSpec { + level: AgentLevel + /** Anteil der halben Containerkante, auf dem die Portraits sitzen. */ + radius: number + /** Portraitdurchmesser in Pixeln bei Referenzbreite `RING_REFERENCE_PX`. */ + avatarPx: number + /** Funktion dauerhaft anzeigen — sonst erst bei Fokus oder im Tooltip. */ + showRole: boolean + /** Gestrichelter Rahmen: Stufe befindet sich noch im Aufbau. */ + dashed: boolean +} + +/** Breite, für die die Pixelwerte gedacht sind; darunter wird linear skaliert. */ +export const RING_REFERENCE_PX = 940 + +export const RING_SPECS: RingSpec[] = [ + { level: AgentLevel.CORE, radius: 0.30, avatarPx: 76, showRole: true, dashed: false }, + { level: AgentLevel.CENTRAL_SERVICES, radius: 0.52, avatarPx: 54, showRole: false, dashed: false }, + { level: AgentLevel.COMMERCIAL, radius: 0.72, avatarPx: 54, showRole: false, dashed: false }, + { level: AgentLevel.RESIDENTIAL, radius: 0.90, avatarPx: 50, showRole: false, dashed: true }, +] + +export function ringSpecFor(level: AgentLevel): RingSpec { + return RING_SPECS.find(s => s.level === level) ?? RING_SPECS[RING_SPECS.length - 1] +} + +export interface RingNodePosition { + /** Position in Prozent der Containerkante, bezogen auf die Mitte. */ + leftPct: number + topPct: number + angleDeg: number +} + +export interface ArcOptions { + /** Startwinkel in Grad; 0 = oben, im Uhrzeigersinn wachsend. */ + startDeg?: number + /** Überstrichener Winkel. 360 = voller Kreis, 180 = Halbkreis. */ + sweepDeg?: number + /** + * Bei geschlossenem Kreis liegen Anfang und Ende aufeinander, deshalb wird + * durch `count` geteilt. Bei einem offenen Bogen soll der letzte Eintrag am + * Endwinkel stehen, deshalb durch `count - 1`. + */ + closed?: boolean +} + +/** + * Verteilt `count` Knoten gleichmässig auf einem Bogen. + * Rückgabe in Prozent, damit die Darstellung containerrelativ bleibt. + */ +export function layoutArc( + count: number, + radius: number, + { startDeg = 0, sweepDeg = 360, closed = true }: ArcOptions = {}, +): RingNodePosition[] { + if (count <= 0) return [] + if (count === 1) { + const rad = ((startDeg - 90) * Math.PI) / 180 + return [{ + leftPct: 50 + Math.cos(rad) * radius * 50, + topPct: 50 + Math.sin(rad) * radius * 50, + angleDeg: startDeg, + }] + } + + const divisor = closed ? count : count - 1 + return Array.from({ length: count }, (_, i) => { + const angleDeg = startDeg + (sweepDeg * i) / divisor + // -90°, damit 0° oben liegt statt rechts + const rad = ((angleDeg - 90) * Math.PI) / 180 + return { + leftPct: 50 + Math.cos(rad) * radius * 50, + topPct: 50 + Math.sin(rad) * radius * 50, + angleDeg, + } + }) +} + +/** + * Winkel der aktiven Position im Organigramm-Halbkreis: unten mittig. + * Der Halbkreis wird oben abgeschnitten, unten steht der gewählte Agent. + */ +export const WHEEL_ACTIVE_ANGLE_DEG = 180 + +/** Bogen, auf dem das Agentenrad seine Portraits verteilt. */ +export const WHEEL_ARC = { + startDeg: 90, + sweepDeg: 180, +} as const diff --git a/src/components/team/index.ts b/src/components/team/index.ts index ed8db54..0ba079d 100644 --- a/src/components/team/index.ts +++ b/src/components/team/index.ts @@ -2,6 +2,17 @@ export { AgentAvatar } from './AgentAvatar' export type { AgentAvatarSize } from './AgentAvatar' +export { AgentAvatarGroup } from './AgentAvatarGroup' +export { AGENT_PHOTOS } from './agentPhotos' +export { + RING_SPECS, + RING_REFERENCE_PX, + ringSpecFor, + layoutArc, + WHEEL_ARC, + WHEEL_ACTIVE_ANGLE_DEG, +} from './agentRingConfig' +export type { RingSpec, RingNodePosition, ArcOptions } from './agentRingConfig' export { AgentStatusBadge, @@ -16,10 +27,20 @@ export { export { TeamPageHeader } from './TeamPageHeader' export { TeamSectionHeader } from './TeamSectionHeader' export { TeamKpiSection } from './TeamKpiSection' -export { AgentCard } from './AgentCard' + +// Kreisdarstellung der Belegschaft — ersetzt das frühere Kernteam-Kartenraster +export { TeamLevelTabs } from './TeamLevelTabs' +export { TeamOverviewRing } from './TeamOverviewRing' +export { AgentRing } from './AgentRing' +export { AgentRingNode } from './AgentRingNode' +export { AgentPreviewPopover } from './AgentPreviewPopover' export { ConnectionSummaryList } from './ConnectionSummaryList' // Personalverwaltung +export { AgentDossier, DOSSIER_TABS, DEFAULT_DOSSIER_SEGMENT, isDossierSegment } from './AgentDossier' +export type { DossierSegment } from './AgentDossier' +export { AgentDescriptionTab } from './AgentDescriptionTab' +export { RotatableAgentWheel } from './RotatableAgentWheel' export { AgentListPanel } from './AgentListPanel' export { AgentDossierHeader } from './AgentDossierHeader' export { AgentInfoBox } from './AgentInfoBox' diff --git a/src/domain/agentDirectory.ts b/src/domain/agentDirectory.ts new file mode 100644 index 0000000..df490c4 --- /dev/null +++ b/src/domain/agentDirectory.ts @@ -0,0 +1,66 @@ +/** + * Property On — Verzeichnis der digitalen Belegschaft. + * + * Ergänzt `teamAgent.ts`, ersetzt es nicht: `TeamAgent` ist das vollständige + * Personaldossier der sieben Kernteammitglieder, `AgentDirectoryEntry` sind die + * Stammdaten aller 36 Mitarbeitenden für die Kreisdarstellung. + * + * Die Stufenbezeichnungen sind aus dem Konzeptdokument übernommen und nicht neu + * erfunden. Sie sind kumulativ zu lesen — «+ Zentrale Dienste» meint Kernteam + * plus Zentrale Dienste, nicht Zentrale Dienste allein. + */ + +/** Personalstufe — bestimmt zugleich den Ring in der Kreisdarstellung. */ +export const AgentLevel = { + CORE: 'CORE', + CENTRAL_SERVICES: 'CENTRAL_SERVICES', + COMMERCIAL: 'COMMERCIAL', + RESIDENTIAL: 'RESIDENTIAL', +} as const +export type AgentLevel = typeof AgentLevel[keyof typeof AgentLevel] + +/** Ringreihenfolge von innen nach aussen. */ +export const AGENT_LEVEL_ORDER: AgentLevel[] = [ + AgentLevel.CORE, + AgentLevel.CENTRAL_SERVICES, + AgentLevel.COMMERCIAL, + AgentLevel.RESIDENTIAL, +] + +export const AgentDepartment = { + CENTRAL_SERVICES: 'CENTRAL_SERVICES', + COMMERCIAL: 'COMMERCIAL', + RESIDENTIAL: 'RESIDENTIAL', +} as const +export type AgentDepartment = typeof AgentDepartment[keyof typeof AgentDepartment] + +/** Aufbaustand aus dem Konzept — nicht zu verwechseln mit `AgentStatus` (aktiv/pausiert). */ +export const AgentRollout = { + PROTOTYPE: 'PROTOTYPE', + IN_PROGRESS: 'IN_PROGRESS', + PLANNED: 'PLANNED', +} as const +export type AgentRollout = typeof AgentRollout[keyof typeof AgentRollout] + +export interface AgentDirectoryEntry { + id: string + name: string + role: string + email: string + level: AgentLevel + department: AgentDepartment + rollout: AgentRollout + /** true → vollständiges Personaldossier vorhanden. */ + isCore: boolean +} + +/** + * Minimalform für die Avatardarstellung. Bewusst schmal, damit sowohl ein + * vollständiger `TeamAgent` als auch ein blosser Verzeichniseintrag hineinpasst + * — sonst bräuchte es zwei fast identische Avatarkomponenten. + */ +export interface AgentAvatarSubject { + id: string + name: string + role: string +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2cb3fcd..1986183 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -330,6 +330,40 @@ export const AGENT_PERIOD_LABELS: Record = { ALL: 'Gesamter Zeitraum', } +/** + * Personalstufen der digitalen Belegschaft. + * + * Wortlaut unverändert aus dem Konzeptdokument übernommen — die Reiter sind + * kumulativ zu lesen: «+ Zentrale Dienste» meint Kernteam plus Zentrale Dienste. + */ +export const AGENT_LEVEL_LABELS: Record = { + CORE: 'Kernteam', + CENTRAL_SERVICES: 'Zentrale Dienste', + COMMERCIAL: 'GB Kommerzielle Immobilien', + RESIDENTIAL: 'GB Wohnen', +} + +/** Beschriftung der Filterreiter — ebenfalls Wortlaut des Konzepts. */ +export const AGENT_LEVEL_TAB_LABELS: Record = { + CORE: 'Kernteam', + CENTRAL_SERVICES: '+ Zentrale Dienste', + COMMERCIAL: '+ GB Kommerziell', + RESIDENTIAL: 'Alle 36', +} + +export const AGENT_DEPARTMENT_LABELS: Record = { + CENTRAL_SERVICES: 'Zentrale Dienste', + COMMERCIAL: 'GB Kommerzielle Immobilien', + RESIDENTIAL: 'GB Wohnen', +} + +/** Aufbaustand aus dem Konzept — nicht der Betriebszustand aktiv/pausiert. */ +export const AGENT_ROLLOUT_LABELS: Record = { + PROTOTYPE: 'Prototyp läuft', + IN_PROGRESS: 'Im Aufbau', + PLANNED: 'Geplant', +} + /** Simulierte Ladezeit für Demo-Aktionen (§19.1: 300–800 ms). */ export const AGENT_SIM_DELAY_MS = 420 export const AGENT_SIM_DELAY_SLOW_MS = 760 diff --git a/src/mock-data/agentDirectory.ts b/src/mock-data/agentDirectory.ts new file mode 100644 index 0000000..231e241 --- /dev/null +++ b/src/mock-data/agentDirectory.ts @@ -0,0 +1,385 @@ +/** + * Property On — Verzeichnis aller digitalen Mitarbeitenden. + * + * Quelle ist das Konzeptdokument «Agenten_Bewirtschaftung_v2.html» (Stand + * 24.07.2026): Namen, Funktionen, Personalstufen, Bereiche und Rollout-Status + * sind daraus unverändert übernommen, ebenso die Porträts. + * + * Abgrenzung zu `mockTeamAgents`: dieses Verzeichnis trägt nur die Stammdaten, + * die für die Kreisdarstellung nötig sind. Ein vollständiges Personaldossier — + * Aufgaben, Kanäle, Systemzugänge, Einstellungen, Protokoll — besitzen + * ausschliesslich die sieben Kernteammitglieder in `src/mock-data/agents/`. + * Für die übrigen 29 werden bewusst keine Inhalte erfunden. + * + * ACHTUNG: generiert aus dem Konzept. Nicht von Hand pflegen — bei einer neuen + * Konzeptfassung neu erzeugen, sonst driften Verzeichnis und Quelle auseinander. + */ + +import type { AgentDirectoryEntry } from '../domain/agentDirectory' +import { AgentLevel, AgentDepartment, AgentRollout } from '../domain/agentDirectory' + +export const agentDirectory: AgentDirectoryEntry[] = [ + { + id: 'bruno', + name: 'Bruno', + role: 'Besichtigungs-Briefing', + email: 'bruno@property-on.ch', + level: AgentLevel.CORE, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.IN_PROGRESS, + isCore: true, + }, + { + id: 'ferdi', + name: 'Ferdi', + role: 'Fristen-Wächter', + email: 'ferdi@property-on.ch', + level: AgentLevel.CORE, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PROTOTYPE, + isCore: true, + }, + { + id: 'lea', + name: 'Lea', + role: 'Anfragen-Manager', + email: 'lea@property-on.ch', + level: AgentLevel.CORE, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PROTOTYPE, + isCore: true, + }, + { + id: 'livia', + name: 'Livia', + role: 'Lage-Analyst', + email: 'livia@property-on.ch', + level: AgentLevel.CORE, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.IN_PROGRESS, + isCore: true, + }, + { + id: 'nora', + name: 'Nora', + role: 'Markt-Scout', + email: 'nora@property-on.ch', + level: AgentLevel.CORE, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PROTOTYPE, + isCore: true, + }, + { + id: 'reto', + name: 'Reto', + role: 'Recap-Agent', + email: 'reto@property-on.ch', + level: AgentLevel.CORE, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.IN_PROGRESS, + isCore: true, + }, + { + id: 'sina', + name: 'Sina', + role: 'Vertragsauskunft', + email: 'sina@property-on.ch', + level: AgentLevel.CORE, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.IN_PROGRESS, + isCore: true, + }, + { + id: 'ada', + name: 'Ada', + role: 'Datenqualitäts-Wächterin', + email: 'ada@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PROTOTYPE, + isCore: false, + }, + { + id: 'carla', + name: 'Carla', + role: 'Bestands-Radar', + email: 'carla@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'diego', + name: 'Diego', + role: 'Decision-Brief-Ersteller', + email: 'diego@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PROTOTYPE, + isCore: false, + }, + { + id: 'dora', + name: 'Dora', + role: 'Dossier-Erstellerin', + email: 'dora@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'emil', + name: 'Emil', + role: 'Unterlagen-Versand', + email: 'emil@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'filip', + name: 'Filip', + role: 'Ablage-Agent', + email: 'filip@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'kira', + name: 'Kira', + role: 'Konkurrenz-Beobachterin', + email: 'kira@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'lars', + name: 'Lars', + role: 'Archiv-Digitalisierer', + email: 'lars@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'mia', + name: 'Mia', + role: 'Mietpreis-Benchmarkerin', + email: 'mia@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'paul', + name: 'Paul', + role: 'Pipeline-Führer', + email: 'paul@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'pia', + name: 'Pia', + role: 'Mietantritt-Koordinatorin', + email: 'pia@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'rea', + name: 'Rea', + role: 'Referenz-Einholerin', + email: 'rea@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'rico', + name: 'Rico', + role: 'Eigentümer-Reporter', + email: 'rico@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'uli', + name: 'Uli', + role: 'Übergabe-Assistent', + email: 'uli@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'vela', + name: 'Vela', + role: 'Cockpit-Agentin', + email: 'vela@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'vera', + name: 'Vera', + role: 'Inserat-Texterin', + email: 'vera@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'vinz', + name: 'Vinz', + role: 'Vertragsentwurf-Assistent', + email: 'vinz@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'vito', + name: 'Vito', + role: 'Objekt-Erfasser', + email: 'vito@property-on.ch', + level: AgentLevel.CENTRAL_SERVICES, + department: AgentDepartment.CENTRAL_SERVICES, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'beat', + name: 'Beat', + role: 'Zahlungs-Frühwarner', + email: 'beat@property-on.ch', + level: AgentLevel.COMMERCIAL, + department: AgentDepartment.COMMERCIAL, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'fabio', + name: 'Fabio', + role: 'Fit-out-Rechner', + email: 'fabio@property-on.ch', + level: AgentLevel.COMMERCIAL, + department: AgentDepartment.COMMERCIAL, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'nadia', + name: 'Nadia', + role: 'Umsatzmiete-Abrechnerin', + email: 'nadia@property-on.ch', + level: AgentLevel.COMMERCIAL, + department: AgentDepartment.COMMERCIAL, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'oskar', + name: 'Oskar', + role: 'Offerten-Schreiber', + email: 'oskar@property-on.ch', + level: AgentLevel.COMMERCIAL, + department: AgentDepartment.COMMERCIAL, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'otto', + name: 'Otto', + role: 'Options-Vorbereiter', + email: 'otto@property-on.ch', + level: AgentLevel.COMMERCIAL, + department: AgentDepartment.COMMERCIAL, + rollout: AgentRollout.PLANNED, + isCore: false, + }, + { + id: 'sven', + name: 'Sven', + role: 'Mieter-Check', + email: 'sven@property-on.ch', + level: AgentLevel.COMMERCIAL, + department: AgentDepartment.COMMERCIAL, + rollout: AgentRollout.PROTOTYPE, + isCore: false, + }, + { + id: 'tino', + name: 'Tino', + role: 'Mietzins-Rechner', + email: 'tino@property-on.ch', + level: AgentLevel.COMMERCIAL, + department: AgentDepartment.COMMERCIAL, + rollout: AgentRollout.IN_PROGRESS, + isCore: false, + }, + { + id: 'gian', + name: 'Gian', + role: 'Privat-Check', + email: 'gian@property-on.ch', + level: AgentLevel.RESIDENTIAL, + department: AgentDepartment.RESIDENTIAL, + rollout: AgentRollout.IN_PROGRESS, + isCore: false, + }, + { + id: 'ida', + name: 'Ida', + role: 'Nebenkosten-Assistentin', + email: 'ida@property-on.ch', + level: AgentLevel.RESIDENTIAL, + department: AgentDepartment.RESIDENTIAL, + rollout: AgentRollout.IN_PROGRESS, + isCore: false, + }, + { + id: 'jana', + name: 'Jana', + role: 'Wechsel-Koordinatorin Wohnen', + email: 'jana@property-on.ch', + level: AgentLevel.RESIDENTIAL, + department: AgentDepartment.RESIDENTIAL, + rollout: AgentRollout.IN_PROGRESS, + isCore: false, + }, + { + id: 'zeno', + name: 'Zeno', + role: 'Referenzzins-Spezialist', + email: 'zeno@property-on.ch', + level: AgentLevel.RESIDENTIAL, + department: AgentDepartment.RESIDENTIAL, + rollout: AgentRollout.IN_PROGRESS, + isCore: false, + }, +] + +/** Nur die sieben Kernteammitglieder — sie besitzen ein vollständiges Dossier. */ +export const coreAgentIds: string[] = agentDirectory.filter(a => a.isCore).map(a => a.id) diff --git a/src/pages/supply/KanaeleSysteme.tsx b/src/pages/supply/KanaeleSysteme.tsx index be6b84b..1ba77a0 100644 --- a/src/pages/supply/KanaeleSysteme.tsx +++ b/src/pages/supply/KanaeleSysteme.tsx @@ -63,18 +63,17 @@ export default function KanaeleSysteme() { gap: 1.5, gridTemplateColumns: { xs: '1fr', - md: 'repeat(2, 1fr)', - xl: 'repeat(3, 1fr)', + md: 'repeat(2, minmax(0, 1fr))', + xl: 'repeat(4, minmax(0, 1fr))', }, }} > {isLoading - ? [0, 1, 2, 3, 4, 5].map((i) => ) + ? [0, 1, 2, 3, 4, 5, 6, 7].map((i) => ) : connections.map((connection) => ( t.segment === value) +function isView(value: string | null): value is PersonnelView { + return value === VIEW.DOSSIER || value === VIEW.ORG } +/** Nur die sieben Kernteammitglieder — sie besitzen ein Personaldossier. */ +const coreEntries = agentDirectory.filter(a => a.isCore) + export default function Personalverwaltung() { const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>() + const [searchParams, setSearchParams] = useSearchParams() const navigate = useNavigate() const theme = useTheme() const isCompact = useMediaQuery(theme.breakpoints.down('lg')) const { data: agents = [], isLoading, isError, refetch } = useTeamAgents() - const activeSegment: DossierSegment = isSegment(tab) ? tab : DEFAULT_SEGMENT - const selectedAgent = agents.find(a => a.id === agentId) ?? null + + const view: PersonnelView = isView(searchParams.get(VIEW_PARAM)) + ? (searchParams.get(VIEW_PARAM) as PersonnelView) + : VIEW.DOSSIER + const activeSegment: DossierSegment = isDossierSegment(tab) ? tab : DEFAULT_DOSSIER_SEGMENT + const selectedAgent = useMemo(() => agents.find(a => a.id === agentId) ?? null, [agents, agentId]) // Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres // Dossier wäre für den Nutzer eine Sackgasse. useEffect(() => { if (!agentId && agents.length > 0) { - navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}`, { replace: true }) + navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}?${VIEW_PARAM}=${view}`, { replace: true }) } - }, [agentId, agents, navigate]) + }, [agentId, agents, navigate, view]) - const selectAgent = useCallback( - (id: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${id}/${activeSegment}`), - [navigate, activeSegment], + const goTo = useCallback( + (nextAgentId: string, segment: DossierSegment) => { + navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${nextAgentId}/${segment}?${VIEW_PARAM}=${view}`) + }, + [navigate, view], ) - const selectTab = useCallback( + const selectAgent = useCallback( + (id: string) => goTo(id, activeSegment), + [goTo, activeSegment], + ) + + const selectSegment = useCallback( (segment: DossierSegment) => { - if (agentId) navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}/${segment}`) + if (agentId) goTo(agentId, segment) }, - [navigate, agentId], + [goTo, agentId], + ) + + const changeView = useCallback( + (next: PersonnelView | null) => { + if (!next) return + const params = new URLSearchParams(searchParams) + params.set(VIEW_PARAM, next) + setSearchParams(params, { replace: false }) + }, + [searchParams, setSearchParams], + ) + + const dossier = selectedAgent && ( + ) return ( changeView(next)} + aria-label="Ansicht" + > + + + Dossieransicht + + + + Organigramm + + + } /> {isError ? ( refetch()} /> + ) : isLoading ? ( + + ) : !selectedAgent ? ( + + ) : view === VIEW.ORG ? ( + /* Organigramm: Halbkreis oben, Dossier des aktiven Mitarbeitenden darunter */ + + + {dossier} + ) : ( + /* Dossieransicht: Agentenliste links, Dossier rechts */ - {/* Ebene 2: Agentenliste */} - - {/* Ebene 3: Personaldossier */} - {isLoading && } - - {!isLoading && !selectedAgent && ( - - )} - - {selectedAgent && ( - <> - - - - - selectTab(v)} - variant="scrollable" - scrollButtons="auto" - allowScrollButtonsMobile - sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem' } }} - > - {DOSSIER_TABS.map((t) => ( - - ))} - - - - {/* `key` auf die Mitarbeiter-ID: beim Wechsel des Dossiers werden die - Reiter neu aufgebaut. Das ersetzt Zurücksetz-Effekte in jedem - einzelnen Reiter — sonst stünde der unbestätigte Entwurf des zuvor - gewählten Mitarbeiters in einem fremden Personalblatt. */} - - {activeSegment === 'aufgaben' && } - {activeSegment === 'kanaele' && } - {activeSegment === 'systeme' && } - {activeSegment === 'einstellungen' && } - {activeSegment === 'protokoll' && } - - - )} + {dossier} )} diff --git a/src/pages/supply/Teamuebersicht.tsx b/src/pages/supply/Teamuebersicht.tsx index 99610e8..fe0d3fa 100644 --- a/src/pages/supply/Teamuebersicht.tsx +++ b/src/pages/supply/Teamuebersicht.tsx @@ -1,89 +1,57 @@ -import { useCallback } from 'react' -import { useNavigate } from 'react-router' -import { Box, Button } from '@mui/material' -import { RotateCcw } from 'lucide-react' +import { useState } from 'react' +import { Box } from '@mui/material' import { TeamPageHeader, TeamSectionHeader, TeamKpiSection, - AgentCard, + TeamLevelTabs, + TeamOverviewRing, ConnectionSummaryList, } from '../../components/team' -import { CardSkeleton, ErrorState } from '../../components/ui' +import { ErrorState } from '../../components/ui' +import type { AgentLevel } from '../../domain/agentDirectory' +import { AgentLevel as Level } from '../../domain/agentDirectory' import { useTeamAgents } from '../../hooks/useTeamAgents' import { useAgentConnections } from '../../hooks/useAgentConnections' -import { useResetTeamDemo } from '../../hooks/useTeamDemo' import { ROUTES } from '../../lib/constants' /** * Startseite des Property-On-Bereichs. - * Verbindliche Reihenfolge: Auswertung, Kernteam, Kanäle & Systeme (§4.1). + * + * Aufbau: die Belegschaft als Kreis direkt unter dem Seitentitel, darunter die + * Auswertung und die Verbindungen. Der Kreis steht bewusst zuoberst — er ist die + * Antwort auf die Frage, mit der ein Bewirtschafter die Seite öffnet: «wer + * arbeitet hier eigentlich für mich?» + * + * Kopfbereich bewusst ohne Beschreibungstext und ohne «Demo zurücksetzen»: der + * Platz gehört der Visualisierung. */ export default function Teamuebersicht() { - const navigate = useNavigate() - const { data: agents = [], isLoading, isError, refetch } = useTeamAgents() + const [level, setLevel] = useState(Level.CORE) + const { data: agents = [], isError, refetch } = useTeamAgents() const { data: connections = [] } = useAgentConnections() - const resetDemo = useResetTeamDemo() - - const openAgent = useCallback( - (agentId: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}`), - [navigate], - ) return ( - } - disabled={resetDemo.isPending} - onClick={() => resetDemo.mutate()} - sx={{ textTransform: 'none', fontWeight: 600 }} - > - {resetDemo.isPending ? 'Wird zurückgesetzt …' : 'Demo zurücksetzen'} - - } - /> + - + {isError ? ( refetch()} /> ) : ( <> - - - - - - - {isLoading - ? [0, 1, 2, 3].map((i) => ) - : agents.map((agent) => ( - - ))} + + + + - + + + + +