feat(team): Kreisdarstellung der Belegschaft und Organigramm-Ansicht
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>
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -1,73 +1,97 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Avatar, Box, Tooltip } from '@mui/material'
|
import { Avatar, Box, Tooltip } from '@mui/material'
|
||||||
import { Check } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { AgentAvatarSubject } from '../../domain/agentDirectory'
|
||||||
import { AgentStatus } from '../../domain/teamAgent'
|
import type { AgentStatus, AgentAvatarTone } from '../../domain/teamAgent'
|
||||||
|
import { AgentStatus as Status } from '../../domain/teamAgent'
|
||||||
import { AVATAR_TONE_COLOR } from './teamTokens'
|
import { AVATAR_TONE_COLOR } from './teamTokens'
|
||||||
import { AGENT_PHOTOS } from './agentPhotos'
|
import { AGENT_PHOTOS } from './agentPhotos'
|
||||||
import { AGENT_STATUS_LABELS } from '../../lib/constants'
|
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,
|
small: 28,
|
||||||
medium: 40,
|
medium: 40,
|
||||||
large: 64,
|
large: 64,
|
||||||
|
xlarge: 96,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type AgentAvatarSize = keyof typeof SIZES
|
export type AgentAvatarSize = keyof typeof NAMED_SIZES | number
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: TeamAgent
|
|
||||||
size?: AgentAvatarSize
|
|
||||||
/**
|
/**
|
||||||
* Blendet den Statuspunkt ein. Auf der Teamübersicht ist er verbindlich (§6.2):
|
* Bewusst die Minimalform: so passt sowohl ein vollständiger `TeamAgent` als
|
||||||
* ein grüner Haken für aktiv, sonst der Hinweis «Pausiert».
|
* 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
|
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 {
|
function initialsOf(name: string): string {
|
||||||
return name.trim().slice(0, 2).toUpperCase()
|
return name.trim().slice(0, 2).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function resolveSize(size: AgentAvatarSize): number {
|
||||||
* Es gibt keine Porträtbilder für digitale Mitarbeiter — und erfundene
|
return typeof size === 'number' ? size : NAMED_SIZES[size]
|
||||||
* Gesichter wären in einer Kundendemo irreführend. Stattdessen Initialen auf
|
}
|
||||||
* einer je Mitarbeiter festen Farbe.
|
|
||||||
*/
|
export const AgentAvatar = memo(function AgentAvatar({
|
||||||
export const AgentAvatar = memo(function AgentAvatar({ agent, size = 'medium', showStatus = false }: Props) {
|
agent,
|
||||||
const px = SIZES[size]
|
size = 'medium',
|
||||||
const isActive = agent.status === AgentStatus.ACTIVE
|
status,
|
||||||
const statusLabel = AGENT_STATUS_LABELS[agent.status] ?? agent.status
|
showStatus = false,
|
||||||
const badgePx = size === 'large' ? 20 : 14
|
tone,
|
||||||
|
loading = 'lazy',
|
||||||
|
ringColor,
|
||||||
|
ringWidth = 2,
|
||||||
|
}: Props) {
|
||||||
|
const px = resolveSize(size)
|
||||||
const photo = AGENT_PHOTOS[agent.id]
|
const photo = AGENT_PHOTOS[agent.id]
|
||||||
const label = `${agent.name}, ${agent.role}`
|
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 (
|
return (
|
||||||
<Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}>
|
<Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}>
|
||||||
{/* Der zugängliche Name hängt an genau einem Element, nie an zweien:
|
{/* Der zugängliche Name hängt an genau einem Element, nie an zweien:
|
||||||
mit Porträt trägt ihn das von MUI erzeugte <img> über `alt`, ohne
|
mit Porträt am erzeugten <img> über `alt`, ohne Porträt am Container
|
||||||
Porträt der Container über role="img" und aria-label. `alt` allein
|
über role="img" und aria-label. `alt` allein genügt nämlich nicht —
|
||||||
genügt nämlich nicht — ohne `src` rendert MUI gar kein <img>, und der
|
ohne `src` rendert MUI gar kein <img>, der Wert landet nirgends. */}
|
||||||
Wert landet nirgends im DOM. */}
|
|
||||||
<Avatar
|
<Avatar
|
||||||
src={photo}
|
src={photo}
|
||||||
alt={photo ? label : undefined}
|
alt={photo ? label : undefined}
|
||||||
|
slotProps={{ img: { loading } }}
|
||||||
{...(photo ? {} : { role: 'img', 'aria-label': label })}
|
{...(photo ? {} : { role: 'img', 'aria-label': label })}
|
||||||
sx={{
|
sx={{
|
||||||
width: px,
|
width: px,
|
||||||
height: px,
|
height: px,
|
||||||
bgcolor: AVATAR_TONE_COLOR[agent.avatarTone],
|
bgcolor: tone ? AVATAR_TONE_COLOR[tone] : DS_BORDER.strong,
|
||||||
color: DS_TEXT.inverted,
|
color: DS_TEXT.inverted,
|
||||||
fontSize: px * 0.38,
|
fontSize: px * 0.38,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
...(ringColor
|
||||||
|
? { border: `${ringWidth}px solid ${ringColor}`, boxSizing: 'border-box' }
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Fällt das Bild aus, zeigt MUI wieder die Initialen statt eines Lochs. */}
|
{/* Fällt das Bild aus, zeigt MUI wieder die Initialen statt eines Lochs. */}
|
||||||
{initialsOf(agent.name)}
|
{initialsOf(agent.name)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
{showStatus && (
|
{showStatus && status && (
|
||||||
<Tooltip title={statusLabel} arrow>
|
<Tooltip title={statusLabel} arrow>
|
||||||
<Box
|
<Box
|
||||||
aria-label={statusLabel}
|
aria-label={statusLabel}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import { Box, Tooltip, Typography } from '@mui/material'
|
||||||
|
import { agentDirectory } from '../../mock-data/agentDirectory'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 5
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
agentIds: string[]
|
||||||
|
size?: number
|
||||||
|
/** Text vor der Gruppe, z. B. «Genutzt durch». */
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Überlappende Porträtgruppe (§2.3, §7.1).
|
||||||
|
*
|
||||||
|
* Ersetzt die frühere Namensliste: wer ein System nutzt, ist als Gesicht
|
||||||
|
* schneller erfasst als als Aufzählung. Ab dem sechsten Eintrag steht ein
|
||||||
|
* Zähler, sonst franst die Gruppe in schmalen Karten aus.
|
||||||
|
*
|
||||||
|
* Die Namen bleiben über Tooltip und `aria-label` erreichbar — die Information
|
||||||
|
* darf nicht ausschliesslich im Bild stecken.
|
||||||
|
*/
|
||||||
|
export const AgentAvatarGroup = memo(function AgentAvatarGroup({
|
||||||
|
agentIds,
|
||||||
|
size = 24,
|
||||||
|
label,
|
||||||
|
}: Props) {
|
||||||
|
const { visible, overflow, allNames } = useMemo(() => {
|
||||||
|
const entries = agentIds
|
||||||
|
.map(id => agentDirectory.find(a => a.id === id))
|
||||||
|
.filter((a): a is NonNullable<typeof a> => !!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 (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
Noch keinem Mitarbeitenden zugewiesen
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
|
{label && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, flexShrink: 0 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
role="list"
|
||||||
|
aria-label={allNames}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', pl: 0.5 }}
|
||||||
|
>
|
||||||
|
{visible.map((agent) => (
|
||||||
|
<Tooltip key={agent.id} title={`${agent.name} · ${agent.role}`} arrow>
|
||||||
|
<Box
|
||||||
|
role="listitem"
|
||||||
|
sx={{
|
||||||
|
ml: -0.5,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `2px solid ${DS_BG.surface}`,
|
||||||
|
display: 'flex',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
'&:hover': { transform: 'translateY(-2px)', zIndex: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentAvatar agent={agent} size={size} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<Tooltip title={overflow.map(a => a.name).join(', ')} arrow>
|
||||||
|
<Box
|
||||||
|
role="listitem"
|
||||||
|
sx={{
|
||||||
|
ml: -0.5,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `2px solid ${DS_BG.surface}`,
|
||||||
|
bgcolor: DS_BORDER.muted,
|
||||||
|
color: DS_TEXT.secondary,
|
||||||
|
fontSize: size * 0.36,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{overflow.length}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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 (
|
|
||||||
<Box
|
|
||||||
component="article"
|
|
||||||
sx={{
|
|
||||||
border: `1px solid ${DS_BORDER.default}`,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: DS_BG.surface,
|
|
||||||
boxShadow: DS_SHADOW.card,
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1.25,
|
|
||||||
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
|
|
||||||
'&:hover': { borderColor: DS_TEXT.brand, boxShadow: DS_SHADOW.panel },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
|
||||||
<AgentAvatar agent={agent} size="medium" showStatus />
|
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary, lineHeight: 1.3 }}>
|
|
||||||
{agent.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
|
||||||
{agent.role}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
|
||||||
Personalnummer {agent.personnelNumber}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, flex: 1 }}>
|
|
||||||
{agent.shortDescription}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
|
||||||
<AgentStatusBadge status={agent.status} />
|
|
||||||
<AgentAutonomyBadge autonomy={agent.autonomyLevel} note={agent.autonomyNote} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{headline && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
gap: 0.75,
|
|
||||||
pt: 1,
|
|
||||||
borderTop: `1px solid ${DS_BORDER.muted}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', color: DS_TEXT.primary }}>
|
|
||||||
{headline.value}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
|
||||||
{headline.label}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
|
||||||
{agent.lastRun ? `Zuletzt aktiv ${formatTeamRelative(agent.lastRun)}` : 'Noch nicht gelaufen'}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
endIcon={<ArrowRight size={14} />}
|
|
||||||
onClick={() => onOpen(agent.id)}
|
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
Personalblatt öffnen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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<string> {
|
||||||
|
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 (
|
||||||
|
<Typography
|
||||||
|
component="h3"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: DS_TEXT.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetricItem = memo(function MetricItem({ metric }: { metric: AgentMetric }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '1.5rem', lineHeight: 1.15, color: DS_TEXT.primary }}>
|
||||||
|
{metric.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>
|
||||||
|
{metric.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ResponsibilityItem = memo(function ResponsibilityItem({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<Typography component="li" variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<Box>
|
||||||
|
{showShortDescription && (
|
||||||
|
<Typography sx={{ maxWidth: '72ch', lineHeight: 1.6, color: DS_TEXT.primary }}>
|
||||||
|
{agent.shortDescription}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<Box sx={{ mx: -3 }}>
|
||||||
|
<AgentInfoBox profile={agent.profile} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box component="section" sx={{ mt: 3 }}>
|
||||||
|
<SectionLabel>Kennzahlen</SectionLabel>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', columnGap: 4, rowGap: 2, mt: 1.25 }}>
|
||||||
|
{agent.metrics.map((metric) => (
|
||||||
|
<MetricItem key={metric.id} metric={metric} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box component="section" sx={{ mt: 3, pt: 2.5, borderTop: `1px solid ${DS_BORDER.muted}` }}>
|
||||||
|
<SectionLabel>Zuständigkeiten</SectionLabel>
|
||||||
|
<Box component="ul" sx={{ m: 0, mt: 1, pl: 2.25, display: 'grid', gap: 0.5 }}>
|
||||||
|
{visibleResponsibilities.map((text) => (
|
||||||
|
<ResponsibilityItem key={text} text={text} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{hiddenResponsibilities > 0 && (
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: DS_TEXT.muted }}>
|
||||||
|
{hiddenResponsibilities} weitere von insgesamt {agent.responsibilities.length} Zuständigkeiten
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<AgentDossierHeader agent={agent} />
|
||||||
|
|
||||||
|
{/* Reiterleiste direkt unter den Stammdaten, auf derselben Fläche — sie
|
||||||
|
gehört zum Kopf des Dossiers und nicht zum Inhalt darunter. */}
|
||||||
|
<Box sx={{ px: 3, bgcolor: DS_BG.surface, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
||||||
|
<Tabs
|
||||||
|
value={activeSegment}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
aria-label="Bereiche des Personaldossiers"
|
||||||
|
sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem' } }}
|
||||||
|
>
|
||||||
|
{DOSSIER_TABS.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.segment}
|
||||||
|
value={tab.segment}
|
||||||
|
label={tab.label}
|
||||||
|
id={tabId(tab.segment)}
|
||||||
|
aria-controls={panelId(tab.segment)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* `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. */}
|
||||||
|
<Box
|
||||||
|
role="tabpanel"
|
||||||
|
id={panelId(activeSegment)}
|
||||||
|
aria-labelledby={tabId(activeSegment)}
|
||||||
|
sx={{ px: 3, py: 2.5 }}
|
||||||
|
>
|
||||||
|
{activeSegment === 'beschrieb' && <AgentDescriptionTab key={agent.id} agent={agent} />}
|
||||||
|
{activeSegment === 'aufgaben' && <AgentTasksTab key={agent.id} agent={agent} />}
|
||||||
|
{activeSegment === 'kanaele' && <AgentChannelsTab key={agent.id} agent={agent} />}
|
||||||
|
{activeSegment === 'systeme' && <AgentSystemsTab key={agent.id} agent={agent} />}
|
||||||
|
{activeSegment === 'einstellungen' && <AgentSettingsTab key={agent.id} agent={agent} />}
|
||||||
|
{activeSegment === 'protokoll' && <AgentProtocolTab key={agent.id} agent={agent} />}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function AgentDossierHeader({ agent }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 2, bgcolor: DS_BG.surface, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 2, bgcolor: DS_BG.surface, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
||||||
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
<AgentAvatar agent={agent} size="large" showStatus />
|
<AgentAvatar agent={agent} size="large" status={agent.status} tone={agent.avatarTone} showStatus />
|
||||||
|
|
||||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||||
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.25rem', color: DS_TEXT.primary, lineHeight: 1.25 }}>
|
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.25rem', color: DS_TEXT.primary, lineHeight: 1.25 }}>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const AgentListRow = memo(function AgentListRow({ agent, selected, onSelect }: R
|
|||||||
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: -2 },
|
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: -2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AgentAvatar agent={agent} size="small" showStatus />
|
<AgentAvatar agent={agent} size="small" status={agent.status} tone={agent.avatarTone} showStatus />
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
<Typography sx={{ fontWeight: selected ? 700 : 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
<Typography sx={{ fontWeight: selected ? 700 : 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Popover
|
||||||
|
open={!!agent && !!anchorEl}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={onClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
slotProps={{ paper: { sx: { p: 2, maxWidth: 300, borderRadius: 2 } } }}
|
||||||
|
>
|
||||||
|
{agent && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
||||||
|
<AgentAvatar agent={agent} size="medium" loading="eager" />
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
||||||
|
{agent.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{agent.role}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.5 }}>
|
||||||
|
{AGENT_LEVEL_LABELS[agent.level] ?? agent.level}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<GenericBadge
|
||||||
|
label={AGENT_ROLLOUT_LABELS[agent.rollout] ?? agent.rollout}
|
||||||
|
semanticVariant="muted"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
|
||||||
|
Für diese Stufe ist noch kein Personaldossier hinterlegt.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Box
|
||||||
|
aria-hidden={!visible}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
transform: visible ? 'scale(1)' : 'scale(0.94)',
|
||||||
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
|
transition: 'opacity 0.35s ease, transform 0.35s ease',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'opacity 0.01s linear', transform: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showGuide && (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
width: `${spec.radius * 100}%`,
|
||||||
|
height: `${spec.radius * 100}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: spec.dashed
|
||||||
|
? `1.5px dashed ${DS_BORDER.strong}`
|
||||||
|
: `1px solid ${DS_BORDER.muted}`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agents.map((agent, index) => {
|
||||||
|
const position = positions[index]
|
||||||
|
if (!position) return null
|
||||||
|
return (
|
||||||
|
<AgentRingNode
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
spec={spec}
|
||||||
|
scale={scale}
|
||||||
|
leftPct={position.leftPct}
|
||||||
|
topPct={position.topPct}
|
||||||
|
selected={agent.id === selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
aria-label={`${agent.name}, ${agent.role}`}
|
||||||
|
aria-current={selected ? 'true' : undefined}
|
||||||
|
onClick={() => 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%)' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={`${agent.name} · ${agent.role}`} arrow enterDelay={200}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: '50%',
|
||||||
|
p: spec.dashed ? '2px' : 0,
|
||||||
|
border: spec.dashed ? `1.5px dashed ${DS_BORDER.strong}` : 'none',
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentAvatar
|
||||||
|
agent={agent}
|
||||||
|
size={px}
|
||||||
|
ringColor={spec.level === 'CORE' ? BADGE_COLORS.gold : DS_BORDER.default}
|
||||||
|
ringWidth={spec.level === 'CORE' ? 2.5 : 1.5}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: `${Math.max(9.5, 11 * scale)}px`,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
color: DS_TEXT.primary,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Höhe wird reserviert, damit die Ringe beim Hover nicht springen */}
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: `${Math.max(8.5, 9.5 * scale)}px`,
|
||||||
|
lineHeight: 1.15,
|
||||||
|
color: DS_TEXT.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
opacity: showRole ? 1 : 0,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.role}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,20 +1,104 @@
|
|||||||
import { memo } from 'react'
|
/**
|
||||||
import { Box, Button, Typography } from '@mui/material'
|
* Property On — Karte einer zentralen Verbindung (§13).
|
||||||
import { Plug, PlugZap, Unplug, Users } from 'lucide-react'
|
*
|
||||||
import type { AgentConnection } from '../../domain/agentConnection'
|
* Verbunden oder nicht verbunden ist ein Zweizustand, also trägt ihn ein Regler
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
* und keine Knopfreihe: einschalten öffnet den Verbindungsassistenten,
|
||||||
import { AgentConnectionStatus } from '../../domain/teamAgent'
|
* ausschalten löst die Trennung aus, die der Aufrufer weiterhin bestätigen
|
||||||
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
|
* 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 {
|
import {
|
||||||
AGENT_CONNECTION_CATEGORY_LABELS,
|
Calendar,
|
||||||
AGENT_ERP_VENDOR_LABELS,
|
Contact,
|
||||||
} from '../../lib/constants'
|
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 { 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, LucideIcon> = {
|
||||||
|
[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 (
|
||||||
|
<Box
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
sx={{
|
||||||
|
width: size, height: size, flexShrink: 0,
|
||||||
|
borderRadius: 1.5, bgcolor: DS_BG.subtle,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={Math.round(size * 0.54)} color={DS_TEXT.secondary} aria-hidden />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connection: AgentConnection
|
connection: AgentConnection
|
||||||
agents: TeamAgent[]
|
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onConfigure: (connectionId: string) => void
|
onConfigure: (connectionId: string) => void
|
||||||
onDisconnect: (connectionId: string) => void
|
onDisconnect: (connectionId: string) => void
|
||||||
@@ -23,7 +107,6 @@ interface Props {
|
|||||||
|
|
||||||
export const ConnectionCard = memo(function ConnectionCard({
|
export const ConnectionCard = memo(function ConnectionCard({
|
||||||
connection,
|
connection,
|
||||||
agents,
|
|
||||||
busy,
|
busy,
|
||||||
onConfigure,
|
onConfigure,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
@@ -31,10 +114,20 @@ export const ConnectionCard = memo(function ConnectionCard({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const isConnected = connection.status === AgentConnectionStatus.CONNECTED
|
const isConnected = connection.status === AgentConnectionStatus.CONNECTED
|
||||||
const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP
|
const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP
|
||||||
const users = connection.usedByAgentIds
|
const purpose = useMemo(() => firstSentence(connection.description), [connection.description])
|
||||||
.map(id => agents.find(a => a.id === id)?.name ?? id)
|
|
||||||
.join(', ')
|
const handleToggle = useCallback(() => {
|
||||||
const grantedPermissions = connection.permissions.filter(p => p.granted)
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -44,103 +137,93 @@ export const ConnectionCard = memo(function ConnectionCard({
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: DS_BG.surface,
|
bgcolor: DS_BG.surface,
|
||||||
boxShadow: DS_SHADOW.card,
|
boxShadow: DS_SHADOW.card,
|
||||||
p: 2,
|
p: 1.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 1.25,
|
gap: 1,
|
||||||
|
transition: 'border-color 0.15s ease',
|
||||||
|
'&:hover': { borderColor: DS_BORDER.strong },
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<ConnectionCategoryIcon category={connection.category} />
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
title={connection.name}
|
||||||
|
sx={{
|
||||||
|
flex: 1, minWidth: 0, color: DS_TEXT.primary,
|
||||||
|
fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.35,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
|
||||||
<Box sx={{ minWidth: 0 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
|
||||||
{connection.name}
|
{connection.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
|
||||||
{AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.category}
|
<Switch
|
||||||
{connection.vendor ? ` · ${AGENT_ERP_VENDOR_LABELS[connection.vendor]}` : ''}
|
size="small"
|
||||||
</Typography>
|
checked={isConnected}
|
||||||
|
disabled={busy || isRoadmap}
|
||||||
|
onChange={handleToggle}
|
||||||
|
slotProps={{ input: { 'aria-label': switchLabel } }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
title={purpose}
|
||||||
|
sx={{
|
||||||
|
color: DS_TEXT.secondary, fontSize: '0.8125rem', lineHeight: 1.4,
|
||||||
|
display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{purpose}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<AgentConnectionStatusBadge status={connection.status} />
|
<AgentConnectionStatusBadge status={connection.status} />
|
||||||
|
<AgentAvatarGroup agentIds={connection.usedByAgentIds} label="Genutzt durch" size={20} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, flex: 1 }}>
|
<Box
|
||||||
{connection.description}
|
sx={{
|
||||||
</Typography>
|
mt: 'auto',
|
||||||
|
pt: 0.75,
|
||||||
{connection.connectionLabel && (
|
borderTop: `1px solid ${DS_BORDER.muted}`,
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
display: 'flex',
|
||||||
Verbindung: «{connection.connectionLabel}»
|
alignItems: 'center',
|
||||||
</Typography>
|
justifyContent: 'space-between',
|
||||||
)}
|
gap: 1,
|
||||||
|
}}
|
||||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
>
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
|
||||||
{connection.accountCount === 1
|
|
||||||
? '1 verbundenes Konto'
|
|
||||||
: `${connection.accountCount} verbundene Konten`}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
|
||||||
<Users size={12} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
|
||||||
{users || 'Noch keinem Mitarbeiter zugewiesen'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
{connection.lastSyncAt
|
{connection.lastSyncAt
|
||||||
? `Letzte Synchronisation ${formatTeamDateTime(connection.lastSyncAt)}`
|
? `Synchronisiert ${formatTeamRelative(connection.lastSyncAt)}`
|
||||||
: 'Noch keine Synchronisation'}
|
: 'Noch keine Synchronisation'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{grantedPermissions.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
|
||||||
{grantedPermissions.map((permission) => (
|
|
||||||
<AgentAccessBadge key={permission.id} access={permission.access} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', pt: 0.5 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={isConnected ? 'outlined' : 'contained'}
|
|
||||||
disabled={busy || isRoadmap}
|
|
||||||
startIcon={isConnected ? <Plug size={14} /> : <PlugZap size={14} />}
|
|
||||||
onClick={() => onConfigure(connection.id)}
|
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{isConnected ? 'Konfigurieren' : 'Verbinden'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
<>
|
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onTest(connection.id)}
|
onClick={handleTest}
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
aria-label={`${connection.name}: Verbindung testen`}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 600, minWidth: 0, px: 0.75 }}
|
||||||
>
|
>
|
||||||
Verbindung testen
|
Verbindung testen
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
startIcon={<Unplug size={14} />}
|
onClick={handleDetails}
|
||||||
onClick={() => onDisconnect(connection.id)}
|
aria-label={`${connection.name}: Details und Berechtigungen`}
|
||||||
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
|
sx={{ textTransform: 'none', fontWeight: 600, minWidth: 0, px: 0.75 }}
|
||||||
>
|
>
|
||||||
Trennen
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{isRoadmap && (
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, alignSelf: 'center' }}>
|
|
||||||
Diese Anbindung ist geplant.
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,57 +1,89 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import type { AgentConnection } from '../../domain/agentConnection'
|
import type { AgentConnection } from '../../domain/agentConnection'
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
import { AgentConnectionStatusBadge } from './AgentBadges'
|
import { AgentConnectionStatusBadge } from './AgentBadges'
|
||||||
|
import { AgentAvatarGroup } from './AgentAvatarGroup'
|
||||||
|
import { ConnectionCategoryIcon } from './ConnectionCard'
|
||||||
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
|
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
|
||||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
import { formatTeamRelative } from '../../lib/teamClock'
|
import { formatTeamRelative } from '../../lib/teamClock'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connections: AgentConnection[]
|
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).
|
* Kompakte Darstellung der Verbindungen auf der Teamübersicht (§6.6).
|
||||||
* Dieselben fachlichen Inhalte wie auf der vollständigen Seite «Kanäle & Systeme»,
|
* Dasselbe Kartenmuster wie auf «Kanäle & Systeme» — Sinnbild, Name, Zustand,
|
||||||
* nur ohne Aktionen — verbunden wird dort, nicht hier.
|
* Porträtgruppe —, nur ohne Aktionen: verbunden wird dort, nicht hier.
|
||||||
*/
|
*/
|
||||||
export function ConnectionSummaryList({ connections, agents }: Props) {
|
export function ConnectionSummaryList({ connections }: Props) {
|
||||||
const nameById = new Map(agents.map(a => [a.id, a.name]))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)', xl: 'repeat(3, 1fr)' },
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
md: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
xl: 'repeat(4, minmax(0, 1fr))',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{connections.map((connection) => {
|
{connections.map((connection) => (
|
||||||
const users = connection.usedByAgentIds.map(id => nameById.get(id) ?? id)
|
<ConnectionSummaryCard key={connection.id} connection={connection} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectionSummaryCard = memo(function ConnectionSummaryCard({
|
||||||
|
connection,
|
||||||
|
}: {
|
||||||
|
connection: AgentConnection
|
||||||
|
}) {
|
||||||
|
const title = AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={connection.id}
|
|
||||||
sx={{
|
sx={{
|
||||||
border: `1px solid ${DS_BORDER.default}`,
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: DS_BG.surface,
|
bgcolor: DS_BG.surface,
|
||||||
p: 1.5,
|
p: 1.25,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 0.5,
|
gap: 0.75,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
<ConnectionCategoryIcon category={connection.category} size={24} />
|
||||||
{AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name}
|
<Typography
|
||||||
|
title={connection.name}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: DS_TEXT.primary,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AgentConnectionStatusBadge status={connection.status} />
|
<AgentConnectionStatusBadge status={connection.status} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
<AgentAvatarGroup agentIds={connection.usedByAgentIds} label="Genutzt durch" size={20} />
|
||||||
{users.length > 0 ? `Genutzt von ${users.join(', ')}` : 'Noch keinem Mitarbeiter zugewiesen'}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
{connection.lastSyncAt
|
{connection.lastSyncAt
|
||||||
@@ -60,7 +92,4 @@ export function ConnectionSummaryList({ connections, agents }: Props) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function StepAgents({ agents, draft, onChange }: StepProps) {
|
|||||||
onChange={() => toggle(agent.id)}
|
onChange={() => toggle(agent.id)}
|
||||||
slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }}
|
slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }}
|
||||||
/>
|
/>
|
||||||
<AgentAvatar agent={agent} size="small" />
|
<AgentAvatar agent={agent} size="small" tone={agent.avatarTone} />
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>) => {
|
||||||
|
dragging.current = { startX: e.clientX }
|
||||||
|
setIsDragging(true)
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragging.current) return
|
||||||
|
setDragDeg((e.clientX - dragging.current.startX) * DRAG_DEGREES_PER_PX)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(
|
||||||
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<Box
|
||||||
|
role="listbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Digitale Mitarbeitende, mit den Pfeiltasten wechseln"
|
||||||
|
aria-activedescendant={active ? `wheel-${active.id}` : undefined}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
height: WHEEL_DIAMETER / 2 + 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'grab',
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
bgcolor: DS_BG.page,
|
||||||
|
borderBottom: `1px solid ${DS_BORDER.default}`,
|
||||||
|
'&:active': { cursor: 'grabbing' },
|
||||||
|
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: -2 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
// Der Kreis ragt nach oben hinaus; der Container schneidet ihn ab.
|
||||||
|
top: -WHEEL_DIAMETER / 2 + 24,
|
||||||
|
width: WHEEL_DIAMETER,
|
||||||
|
height: WHEEL_DIAMETER,
|
||||||
|
ml: `${-WHEEL_DIAMETER / 2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `1px solid ${DS_BORDER.muted}`,
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.35s ease',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agents.map((agent, index) => {
|
||||||
|
const angle = positions[index]
|
||||||
|
const isActive = index === selectedIndex
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={agent.id}
|
||||||
|
id={`wheel-${agent.id}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentAvatar
|
||||||
|
agent={agent}
|
||||||
|
size={isActive ? AVATAR_PX + 14 : AVATAR_PX}
|
||||||
|
loading="eager"
|
||||||
|
ringColor={isActive ? BADGE_COLORS.gold : DS_BORDER.default}
|
||||||
|
ringWidth={isActive ? 3 : 1.5}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontWeight: isActive ? 700 : 600,
|
||||||
|
fontSize: isActive ? '0.9375rem' : '0.8125rem',
|
||||||
|
color: isActive ? DS_TEXT.primary : DS_TEXT.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{agent.name}
|
||||||
|
</Typography>
|
||||||
|
{isActive && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}>
|
||||||
|
{agent.role}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ position: 'absolute', left: 16, bottom: 8, color: DS_TEXT.muted }}
|
||||||
|
>
|
||||||
|
Ziehen oder Pfeiltasten, um zu wechseln — {agents.length} Mitarbeitende
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<AgentLevel, number>()
|
||||||
|
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 (
|
||||||
|
<Tabs
|
||||||
|
value={value}
|
||||||
|
onChange={(_, next: AgentLevel) => 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) => (
|
||||||
|
<Tab
|
||||||
|
key={level}
|
||||||
|
value={level}
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||||
|
<span>{AGENT_LEVEL_TAB_LABELS[level] ?? level}</span>
|
||||||
|
<Typography component="span" variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{cumulativeCounts.get(level)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { memo } from 'react'
|
import { memo, useMemo } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import type { ReactNode } from 'react'
|
||||||
import { AlertTriangle, Building2, Radio } from 'lucide-react'
|
import { Box, Tooltip, Typography } from '@mui/material'
|
||||||
import type { AgentWorkItem } from '../../domain/agentWorkItem'
|
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 type { TeamAgent } from '../../domain/teamAgent'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
|
import { AgentWorkItemStatusBadge } from './AgentBadges'
|
||||||
import {
|
import {
|
||||||
AGENT_CHANNEL_LABELS,
|
AGENT_CHANNEL_LABELS,
|
||||||
|
AGENT_PRIORITY_LABELS,
|
||||||
AGENT_WORK_ITEM_KIND_LABELS,
|
AGENT_WORK_ITEM_KIND_LABELS,
|
||||||
} from '../../lib/constants'
|
} 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'
|
import { formatTeamDateTime, formatTeamRelative } from '../../lib/teamClock'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,17 +25,67 @@ interface Props {
|
|||||||
onSelect: (id: string) => void
|
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<Record<AgentWorkItemPriority, string>> = {
|
||||||
|
[Priority.CRITICAL]: DS_TEXT.error,
|
||||||
|
[Priority.HIGH]: DS_TEXT.warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dezentes Trennzeichen statt eingefärbter Flächen zwischen den Fussangaben. */
|
||||||
|
function MetaDot() {
|
||||||
|
return (
|
||||||
|
<Typography aria-hidden component="span" variant="caption" sx={{ color: DS_TEXT.disabled }}>
|
||||||
|
·
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaEntry({ icon, text }: { icon?: ReactNode; text: string }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||||
|
{icon}
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Eintrag im Bearbeitungsverlauf.
|
* Ein Eintrag im Bearbeitungsverlauf.
|
||||||
*
|
*
|
||||||
* `React.memo` ist hier nicht optional: die Liste rendert bis zu 14 Karten und
|
* `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
|
* 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).
|
* 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) {
|
export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, onSelect }: Props) {
|
||||||
const kindLabel = AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind
|
const kindLabel = AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind
|
||||||
const channelLabel = AGENT_CHANNEL_LABELS[item.sourceChannel] ?? item.sourceChannel
|
const channelLabel = AGENT_CHANNEL_LABELS[item.sourceChannel] ?? item.sourceChannel
|
||||||
const timestamp = item.completedAt ?? item.createdAt
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -48,20 +104,41 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
|
|||||||
border: `1px solid ${selected ? DS_TEXT.brand : DS_BORDER.default}`,
|
border: `1px solid ${selected ? DS_TEXT.brand : DS_BORDER.default}`,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: DS_BG.surface,
|
bgcolor: DS_BG.surface,
|
||||||
p: 1.75,
|
p: 2.5,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card,
|
boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card,
|
||||||
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
|
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
|
||||||
'&:hover': { borderColor: DS_TEXT.brand },
|
'&:hover': { borderColor: DS_BORDER.strong },
|
||||||
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 },
|
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 },
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||||
{agent && <AgentAvatar agent={agent} size="medium" />}
|
{agent && (
|
||||||
|
<Tooltip
|
||||||
|
arrow
|
||||||
|
title={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', fontWeight: 700 }}>
|
||||||
|
{agent.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||||
|
{agent.role}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Wrapper, weil MUI dem Kind eine Referenz anhängt — `AgentAvatar`
|
||||||
|
ist memoisiert und nimmt selbst keine entgegen. */}
|
||||||
|
<Box sx={{ display: 'flex', flexShrink: 0 }}>
|
||||||
|
<AgentAvatar agent={agent} size={AVATAR_PX} tone={agent.avatarTone} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
{/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */}
|
{/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
|
||||||
{agent?.name ?? 'Unbekannt'}
|
{agent?.name ?? 'Unbekannt'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -73,62 +150,49 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.5 }}>
|
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 1 }}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.5 }}>
|
||||||
{item.summary}
|
{item.summary}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* 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.requiresDecision && item.escalationReason && (
|
||||||
<Box
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', mt: 1.5 }}>
|
||||||
sx={{
|
<HelpCircle size={14} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
|
||||||
display: 'flex',
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||||
gap: 0.75,
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
mt: 1,
|
|
||||||
p: 1,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
bgcolor: DS_SURFACE.warning.bg,
|
|
||||||
border: `1px solid ${DS_SURFACE.warning.border}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertTriangle size={14} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark }}>
|
|
||||||
{item.escalationReason}
|
{item.escalationReason}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fusszeile: Vorgangstyp, Objektbezug, Quellkanal, Status */}
|
{/* Fusszeile: Ausnahmen zuerst, danach Vorgangstyp, Objektbezug, Kanal */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap', mt: 2 }}>
|
||||||
<AgentPriorityBadge priority={item.priority} />
|
{priorityAccent && (
|
||||||
<AgentWorkItemStatusBadge status={item.status} />
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<AlertTriangle size={13} color={priorityAccent} aria-hidden style={{ flexShrink: 0 }} />
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
<Typography variant="caption" sx={{ color: priorityAccent, fontWeight: 700 }}>
|
||||||
{kindLabel}
|
{AGENT_PRIORITY_LABELS[item.priority] ?? item.priority}
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* 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) && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
|
|
||||||
<Building2 size={12} color={DS_TEXT.muted} aria-hidden />
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
|
||||||
{[item.objectId, item.objectLabel].filter(Boolean).join(' · ')}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
|
{showStatus && <AgentWorkItemStatusBadge status={item.status} />}
|
||||||
<Radio size={12} color={DS_TEXT.muted} aria-hidden />
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
<MetaEntry text={kindLabel} />
|
||||||
{channelLabel}
|
|
||||||
</Typography>
|
{objectText && (
|
||||||
</Box>
|
<>
|
||||||
|
<MetaDot />
|
||||||
|
<MetaEntry icon={<Building2 size={12} color={DS_TEXT.muted} aria-hidden />} text={objectText} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MetaDot />
|
||||||
|
<MetaEntry icon={<Radio size={12} color={DS_TEXT.muted} aria-hidden />} text={channelLabel} />
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto', whiteSpace: 'nowrap' }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto', whiteSpace: 'nowrap' }}>
|
||||||
{formatTeamRelative(timestamp)}
|
{formatTeamRelative(timestamp)}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function WorkItemDetailDrawer() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{agent && <AgentAvatar agent={agent} size="medium" />}
|
{agent && <AgentAvatar agent={agent} size="medium" tone={agent.avatarTone} />}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
|
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ interface Props {
|
|||||||
emptyDescription: string
|
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) {
|
export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
|
||||||
const { data: items = [], isLoading, isError, refetch } = useAgentWorkItems(filters)
|
const { data: items = [], isLoading, isError, refetch } = useAgentWorkItems(filters)
|
||||||
const { data: agents = [] } = useTeamAgents()
|
const { data: agents = [] } = useTeamAgents()
|
||||||
@@ -25,7 +31,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
|
<Box sx={{ p: 3, display: 'grid', gap: LIST_GAP }}>
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
@@ -42,7 +48,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
|
<Box sx={{ p: 3, display: 'grid', gap: LIST_GAP }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<WorkItemCard
|
<WorkItemCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property On — Agentenkarte im Kernteam-Raster.
|
|
||||||
*
|
|
||||||
* Getestet wird das Verhalten der Karte gegen echte Mitarbeiter aus dem
|
|
||||||
* Agenten-Katalog (`src/mock-data/teamAgents.ts`), nicht gegen erfundene
|
|
||||||
* Fixtures: die Karte ist die Eintrittstür zum Personalblatt, und der Katalog
|
|
||||||
* ist die fachliche Wahrheit.
|
|
||||||
*
|
|
||||||
* Zeitangaben werden bewusst nicht auf konkrete Werte geprüft — die Demo-Uhr
|
|
||||||
* (`src/lib/teamClock.ts`) schreitet ab Anwendungsstart fort, «vor 3 Stunden»
|
|
||||||
* wäre also kein deterministischer Erwartungswert.
|
|
||||||
*
|
|
||||||
* `cleanup` wird hier ausdrücklich registriert: `vitest.config.ts` läuft ohne
|
|
||||||
* `globals: true`, damit fehlt Testing Library das globale `afterEach` und die
|
|
||||||
* automatische Aufräumroutine greift nicht. Ohne diesen Hook bleiben die
|
|
||||||
* Karten früherer Tests im Dokument stehen und jede Abfrage findet Treffer
|
|
||||||
* mehrfach.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
||||||
import { cleanup, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
|
||||||
import { renderWithProviders } from '../../../test/teamTestUtils'
|
|
||||||
import { AgentCard } from '../AgentCard'
|
|
||||||
import { mockTeamAgents } from '../../../mock-data/teamAgents'
|
|
||||||
import { AgentStatus } from '../../../domain/teamAgent'
|
|
||||||
import type { AgentMetric, TeamAgent } from '../../../domain/teamAgent'
|
|
||||||
|
|
||||||
afterEach(cleanup)
|
|
||||||
|
|
||||||
const WAIT = { timeout: 3000 }
|
|
||||||
|
|
||||||
function agentByPersonnelNumber(personnelNumber: string): TeamAgent {
|
|
||||||
const agent = mockTeamAgents.find(a => 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(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={onOpen} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={onOpen} />)
|
|
||||||
|
|
||||||
expect(onOpen).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('zeigt bei einem pausierten Mitarbeiter «Pausiert» statt «Aktiv»', () => {
|
|
||||||
const paused: TeamAgent = { ...ferdi, status: AgentStatus.PAUSED }
|
|
||||||
renderWithProviders(<AgentCard agent={paused} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={agent} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
const card = screen.getByRole('article')
|
|
||||||
expect(within(card).getByText(/Zuletzt aktiv|Noch nicht gelaufen/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rendert das Porträt des Mitarbeiters', () => {
|
|
||||||
renderWithProviders(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ohneFoto} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
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(<AgentCard agent={ferdi} onOpen={vi.fn()} />)
|
|
||||||
|
|
||||||
// Der Name hängt an genau einem Element: mit Porträt am <img> ü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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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 <div data-testid="location">{location.pathname}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<TeamLevelTabs value={AgentLevel.CORE} onChange={() => {}} />)
|
||||||
|
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(<TeamLevelTabs value={AgentLevel.CORE} onChange={() => {}} />)
|
||||||
|
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(
|
||||||
|
<TeamLevelTabs value={AgentLevel.CORE} onChange={(l) => 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(<TeamOverviewRing level={AgentLevel.RESIDENTIAL} />)
|
||||||
|
|
||||||
|
// 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(<TeamOverviewRing level={AgentLevel.CORE} />)
|
||||||
|
expect(screen.getByText(String(agentDirectory.length))).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('digitale Mitarbeitende')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('führt beim Kernteam ins Personaldossier', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<>
|
||||||
|
<TeamOverviewRing level={AgentLevel.CORE} />
|
||||||
|
<LocationProbe />
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<>
|
||||||
|
<TeamOverviewRing level={AgentLevel.RESIDENTIAL} />
|
||||||
|
<LocationProbe />
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
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(<TeamOverviewRing level={AgentLevel.CORE} />)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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»,
|
* Die Bilder stammen aus dem Konzeptdokument, wo sie als eingebettete JPEGs am
|
||||||
* wo sie als eingebettete JPEGs am jeweiligen Agenten hängen. Sie sind damit
|
* jeweiligen Agenten hängen. Sie sind fachliche Vorgabe, nicht Dekoration: das
|
||||||
* Teil der fachlichen Vorgabe, nicht eine gestalterische Zutat: das digitale
|
* digitale Personalbüro zeigt Mitarbeitende mit Gesicht, keine abstrakten
|
||||||
* Personalbüro zeigt Mitarbeiter mit Gesicht, nicht abstrakte Werkzeuge.
|
* Werkzeuge.
|
||||||
*
|
*
|
||||||
* Die Zuordnung liegt bewusst in der Darstellungsschicht und nicht in
|
* Die Zuordnung liegt bewusst in der Darstellungsschicht und nicht im Domain-Typ
|
||||||
* `domain/teamAgent.ts` oder den Mockdaten. Ein Domain-Typ, der Binärdateien
|
* oder in den Mockdaten. Ein Domain-Modell, das Binärdateien importiert, wäre an
|
||||||
* importiert, wäre an den Bundler gekoppelt und liesse sich weder in einem Test
|
* den Bundler gekoppelt und weder im Test noch später gegen ein echtes Backend
|
||||||
* noch später gegen ein echtes Backend sauber verwenden.
|
* sauber verwendbar.
|
||||||
*
|
*
|
||||||
* Fehlt zu einer ID ein Bild, fällt `AgentAvatar` auf die Initialen zurück.
|
* 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'
|
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 nora from '../../assets/team/nora.jpg'
|
||||||
import reto from '../../assets/team/reto.jpg'
|
import reto from '../../assets/team/reto.jpg'
|
||||||
import sina from '../../assets/team/sina.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<string, string | undefined> = {
|
export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
||||||
bruno,
|
bruno,
|
||||||
@@ -30,4 +61,33 @@ export const AGENT_PHOTOS: Record<string, string | undefined> = {
|
|||||||
nora,
|
nora,
|
||||||
reto,
|
reto,
|
||||||
sina,
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
export { AgentAvatar } from './AgentAvatar'
|
export { AgentAvatar } from './AgentAvatar'
|
||||||
export type { AgentAvatarSize } 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 {
|
export {
|
||||||
AgentStatusBadge,
|
AgentStatusBadge,
|
||||||
@@ -16,10 +27,20 @@ export {
|
|||||||
export { TeamPageHeader } from './TeamPageHeader'
|
export { TeamPageHeader } from './TeamPageHeader'
|
||||||
export { TeamSectionHeader } from './TeamSectionHeader'
|
export { TeamSectionHeader } from './TeamSectionHeader'
|
||||||
export { TeamKpiSection } from './TeamKpiSection'
|
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'
|
export { ConnectionSummaryList } from './ConnectionSummaryList'
|
||||||
|
|
||||||
// Personalverwaltung
|
// 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 { AgentListPanel } from './AgentListPanel'
|
||||||
export { AgentDossierHeader } from './AgentDossierHeader'
|
export { AgentDossierHeader } from './AgentDossierHeader'
|
||||||
export { AgentInfoBox } from './AgentInfoBox'
|
export { AgentInfoBox } from './AgentInfoBox'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -330,6 +330,40 @@ export const AGENT_PERIOD_LABELS: Record<string, string> = {
|
|||||||
ALL: 'Gesamter Zeitraum',
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
CORE: 'Kernteam',
|
||||||
|
CENTRAL_SERVICES: '+ Zentrale Dienste',
|
||||||
|
COMMERCIAL: '+ GB Kommerziell',
|
||||||
|
RESIDENTIAL: 'Alle 36',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_DEPARTMENT_LABELS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
PROTOTYPE: 'Prototyp läuft',
|
||||||
|
IN_PROGRESS: 'Im Aufbau',
|
||||||
|
PLANNED: 'Geplant',
|
||||||
|
}
|
||||||
|
|
||||||
/** Simulierte Ladezeit für Demo-Aktionen (§19.1: 300–800 ms). */
|
/** Simulierte Ladezeit für Demo-Aktionen (§19.1: 300–800 ms). */
|
||||||
export const AGENT_SIM_DELAY_MS = 420
|
export const AGENT_SIM_DELAY_MS = 420
|
||||||
export const AGENT_SIM_DELAY_SLOW_MS = 760
|
export const AGENT_SIM_DELAY_SLOW_MS = 760
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -63,18 +63,17 @@ export default function KanaeleSysteme() {
|
|||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
gridTemplateColumns: {
|
gridTemplateColumns: {
|
||||||
xs: '1fr',
|
xs: '1fr',
|
||||||
md: 'repeat(2, 1fr)',
|
md: 'repeat(2, minmax(0, 1fr))',
|
||||||
xl: 'repeat(3, 1fr)',
|
xl: 'repeat(4, minmax(0, 1fr))',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? [0, 1, 2, 3, 4, 5].map((i) => <CardSkeleton key={i} />)
|
? [0, 1, 2, 3, 4, 5, 6, 7].map((i) => <CardSkeleton key={i} />)
|
||||||
: connections.map((connection) => (
|
: connections.map((connection) => (
|
||||||
<ConnectionCard
|
<ConnectionCard
|
||||||
key={connection.id}
|
key={connection.id}
|
||||||
connection={connection}
|
connection={connection}
|
||||||
agents={agents}
|
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onConfigure={handleConfigure}
|
onConfigure={handleConfigure}
|
||||||
onDisconnect={handleDisconnect}
|
onDisconnect={handleDisconnect}
|
||||||
|
|||||||
@@ -1,132 +1,147 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams, useSearchParams } from 'react-router'
|
||||||
import { Box, Tab, Tabs, useMediaQuery, useTheme } from '@mui/material'
|
import { Box, ToggleButton, ToggleButtonGroup, useMediaQuery, useTheme } from '@mui/material'
|
||||||
|
import { CircleDot, Rows3 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
TeamPageHeader,
|
TeamPageHeader,
|
||||||
AgentListPanel,
|
AgentListPanel,
|
||||||
AgentDossierHeader,
|
AgentDossier,
|
||||||
AgentInfoBox,
|
RotatableAgentWheel,
|
||||||
AgentTasksTab,
|
DEFAULT_DOSSIER_SEGMENT,
|
||||||
AgentChannelsTab,
|
isDossierSegment,
|
||||||
AgentSystemsTab,
|
|
||||||
AgentSettingsTab,
|
|
||||||
AgentProtocolTab,
|
|
||||||
} from '../../components/team'
|
} from '../../components/team'
|
||||||
|
import type { DossierSegment } from '../../components/team'
|
||||||
import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui'
|
import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui'
|
||||||
|
import { agentDirectory } from '../../mock-data/agentDirectory'
|
||||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||||
import { ROUTES } from '../../lib/constants'
|
import { ROUTES } from '../../lib/constants'
|
||||||
import { DS_BG, DS_BORDER } from '../../lib/ds'
|
import { DS_BG } from '../../lib/ds'
|
||||||
|
|
||||||
/** URL-Segment je Dossier-Reiter — Deep-Links müssen erhalten bleiben (§7.4). */
|
const VIEW_PARAM = 'view'
|
||||||
const DOSSIER_TABS = [
|
const VIEW = { DOSSIER: 'dossier', ORG: 'organigramm' } as const
|
||||||
{ segment: 'aufgaben', label: 'Aufgaben' },
|
type PersonnelView = typeof VIEW[keyof typeof VIEW]
|
||||||
{ segment: 'kanaele', label: 'Kanäle' },
|
|
||||||
{ segment: 'systeme', label: 'Systeme' },
|
|
||||||
{ segment: 'einstellungen', label: 'Einstellungen' },
|
|
||||||
{ segment: 'protokoll', label: 'Protokoll' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type DossierSegment = typeof DOSSIER_TABS[number]['segment']
|
function isView(value: string | null): value is PersonnelView {
|
||||||
|
return value === VIEW.DOSSIER || value === VIEW.ORG
|
||||||
const DEFAULT_SEGMENT: DossierSegment = 'aufgaben'
|
|
||||||
|
|
||||||
function isSegment(value: string | undefined): value is DossierSegment {
|
|
||||||
return DOSSIER_TABS.some(t => t.segment === value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nur die sieben Kernteammitglieder — sie besitzen ein Personaldossier. */
|
||||||
|
const coreEntries = agentDirectory.filter(a => a.isCore)
|
||||||
|
|
||||||
export default function Personalverwaltung() {
|
export default function Personalverwaltung() {
|
||||||
const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>()
|
const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
||||||
|
|
||||||
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
|
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
|
// Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres
|
||||||
// Dossier wäre für den Nutzer eine Sackgasse.
|
// Dossier wäre für den Nutzer eine Sackgasse.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId && agents.length > 0) {
|
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(
|
const goTo = useCallback(
|
||||||
(id: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${id}/${activeSegment}`),
|
(nextAgentId: string, segment: DossierSegment) => {
|
||||||
[navigate, activeSegment],
|
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) => {
|
(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 && (
|
||||||
|
<AgentDossier
|
||||||
|
agent={selectedAgent}
|
||||||
|
activeSegment={activeSegment}
|
||||||
|
onSegmentChange={selectSegment}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<TeamPageHeader
|
<TeamPageHeader
|
||||||
title="Personalverwaltung"
|
title="Personalverwaltung"
|
||||||
description="Personaldossier jedes digitalen Mitarbeiters: Aufgaben, Kanäle, Systemzugänge, Einstellungen und Protokoll."
|
actions={
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={view}
|
||||||
|
onChange={(_, next: PersonnelView | null) => changeView(next)}
|
||||||
|
aria-label="Ansicht"
|
||||||
|
>
|
||||||
|
<ToggleButton value={VIEW.DOSSIER} sx={{ textTransform: 'none', gap: 0.75, px: 1.5 }}>
|
||||||
|
<Rows3 size={15} aria-hidden />
|
||||||
|
Dossieransicht
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value={VIEW.ORG} sx={{ textTransform: 'none', gap: 0.75, px: 1.5 }}>
|
||||||
|
<CircleDot size={15} aria-hidden />
|
||||||
|
Organigramm
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState message="Die Personalverwaltung konnte nicht geladen werden." onRetry={() => refetch()} />
|
<ErrorState message="Die Personalverwaltung konnte nicht geladen werden." onRetry={() => refetch()} />
|
||||||
|
) : isLoading ? (
|
||||||
|
<PanelLoadingState />
|
||||||
|
) : !selectedAgent ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Kein Mitarbeitender gewählt"
|
||||||
|
description="Wählen Sie einen digitalen Mitarbeitenden, um sein Personaldossier zu öffnen."
|
||||||
|
/>
|
||||||
|
) : view === VIEW.ORG ? (
|
||||||
|
/* Organigramm: Halbkreis oben, Dossier des aktiven Mitarbeitenden darunter */
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
|
||||||
|
<RotatableAgentWheel
|
||||||
|
agents={coreEntries}
|
||||||
|
selectedId={selectedAgent.id}
|
||||||
|
onSelect={selectAgent}
|
||||||
|
/>
|
||||||
|
{dossier}
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
/* Dossieransicht: Agentenliste links, Dossier rechts */
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: isCompact ? 'column' : 'row', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: isCompact ? 'column' : 'row', overflow: 'hidden' }}>
|
||||||
{/* Ebene 2: Agentenliste */}
|
|
||||||
<AgentListPanel
|
<AgentListPanel
|
||||||
agents={agents}
|
agents={agents}
|
||||||
selectedId={selectedAgent?.id ?? null}
|
selectedId={selectedAgent.id}
|
||||||
onSelect={selectAgent}
|
onSelect={selectAgent}
|
||||||
compact={isCompact}
|
compact={isCompact}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Ebene 3: Personaldossier */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page, minWidth: 0 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page, minWidth: 0 }}>
|
||||||
{isLoading && <PanelLoadingState />}
|
{dossier}
|
||||||
|
|
||||||
{!isLoading && !selectedAgent && (
|
|
||||||
<EmptyState
|
|
||||||
title="Kein Mitarbeiter gewählt"
|
|
||||||
description="Wählen Sie links einen digitalen Mitarbeiter, um sein Personaldossier zu öffnen."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedAgent && (
|
|
||||||
<>
|
|
||||||
<AgentDossierHeader agent={selectedAgent} />
|
|
||||||
<AgentInfoBox profile={selectedAgent.profile} />
|
|
||||||
|
|
||||||
<Box sx={{ px: 3, mt: 2, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
|
||||||
<Tabs
|
|
||||||
value={activeSegment}
|
|
||||||
onChange={(_, v: DossierSegment) => selectTab(v)}
|
|
||||||
variant="scrollable"
|
|
||||||
scrollButtons="auto"
|
|
||||||
allowScrollButtonsMobile
|
|
||||||
sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem' } }}
|
|
||||||
>
|
|
||||||
{DOSSIER_TABS.map((t) => (
|
|
||||||
<Tab key={t.segment} value={t.segment} label={t.label} />
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* `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. */}
|
|
||||||
<Box sx={{ px: 3, py: 2.5 }}>
|
|
||||||
{activeSegment === 'aufgaben' && <AgentTasksTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'kanaele' && <AgentChannelsTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'systeme' && <AgentSystemsTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'einstellungen' && <AgentSettingsTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'protokoll' && <AgentProtocolTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,89 +1,57 @@
|
|||||||
import { useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { Box } from '@mui/material'
|
||||||
import { Box, Button } from '@mui/material'
|
|
||||||
import { RotateCcw } from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
TeamPageHeader,
|
TeamPageHeader,
|
||||||
TeamSectionHeader,
|
TeamSectionHeader,
|
||||||
TeamKpiSection,
|
TeamKpiSection,
|
||||||
AgentCard,
|
TeamLevelTabs,
|
||||||
|
TeamOverviewRing,
|
||||||
ConnectionSummaryList,
|
ConnectionSummaryList,
|
||||||
} from '../../components/team'
|
} 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 { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||||
import { useAgentConnections } from '../../hooks/useAgentConnections'
|
import { useAgentConnections } from '../../hooks/useAgentConnections'
|
||||||
import { useResetTeamDemo } from '../../hooks/useTeamDemo'
|
|
||||||
import { ROUTES } from '../../lib/constants'
|
import { ROUTES } from '../../lib/constants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startseite des Property-On-Bereichs.
|
* 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() {
|
export default function Teamuebersicht() {
|
||||||
const navigate = useNavigate()
|
const [level, setLevel] = useState<AgentLevel>(Level.CORE)
|
||||||
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
|
const { data: agents = [], isError, refetch } = useTeamAgents()
|
||||||
const { data: connections = [] } = useAgentConnections()
|
const { data: connections = [] } = useAgentConnections()
|
||||||
const resetDemo = useResetTeamDemo()
|
|
||||||
|
|
||||||
const openAgent = useCallback(
|
|
||||||
(agentId: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}`),
|
|
||||||
[navigate],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<TeamPageHeader
|
<TeamPageHeader title="Teamübersicht" />
|
||||||
title="Teamübersicht"
|
|
||||||
description="Sieben digitale Mitarbeiter arbeiten in den Systemen der Bewirtschaftung. Sie führen aus, Sie entscheiden."
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RotateCcw size={14} />}
|
|
||||||
disabled={resetDemo.isPending}
|
|
||||||
onClick={() => resetDemo.mutate()}
|
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{resetDemo.isPending ? 'Wird zurückgesetzt …' : 'Demo zurücksetzen'}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 5 }}>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} />
|
<ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Box component="section" sx={{ pt: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1 }}>
|
||||||
|
<TeamLevelTabs value={level} onChange={setLevel} />
|
||||||
|
</Box>
|
||||||
|
<TeamOverviewRing level={level} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box component="section" sx={{ mt: 6 }}>
|
||||||
<TeamKpiSection agents={agents} />
|
<TeamKpiSection agents={agents} />
|
||||||
|
|
||||||
<Box component="section" sx={{ mb: 4 }}>
|
|
||||||
<TeamSectionHeader
|
|
||||||
title="Kernteam"
|
|
||||||
description="Alle sieben digitalen Mitarbeiter mit Zuständigkeit und aktueller Kennzahl."
|
|
||||||
quickLink={{ label: 'Zur Personalverwaltung', to: ROUTES.SUPPLY.TEAM_PERSONNEL }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gap: 1.5,
|
|
||||||
gridTemplateColumns: {
|
|
||||||
xs: '1fr',
|
|
||||||
sm: 'repeat(2, 1fr)',
|
|
||||||
lg: 'repeat(3, 1fr)',
|
|
||||||
xl: 'repeat(4, 1fr)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? [0, 1, 2, 3].map((i) => <CardSkeleton key={i} />)
|
|
||||||
: agents.map((agent) => (
|
|
||||||
<AgentCard key={agent.id} agent={agent} onOpen={openAgent} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box component="section">
|
<Box component="section" sx={{ mt: 2 }}>
|
||||||
<TeamSectionHeader
|
<TeamSectionHeader
|
||||||
title="Kanäle & Systeme"
|
title="Kanäle & Systeme"
|
||||||
description="Womit das digitale Team heute verbunden ist."
|
description="Womit das digitale Team heute verbunden ist."
|
||||||
|
|||||||
@@ -10,3 +10,21 @@ import { cleanup } from '@testing-library/react'
|
|||||||
* Datei an «found multiple elements».
|
* Datei an «found multiple elements».
|
||||||
*/
|
*/
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom bringt keinen `ResizeObserver` mit. Komponenten, die ihre Breite messen
|
||||||
|
* — etwa die Kreisdarstellung der Belegschaft —, würden sonst schon beim Mounten
|
||||||
|
* mit einem ReferenceError abbrechen.
|
||||||
|
*
|
||||||
|
* Der Ersatz beobachtet nichts, er hält nur die Schnittstelle bereit. Die
|
||||||
|
* Komponenten sind so gebaut, dass sie ohne Messwerte auf eine sinnvolle
|
||||||
|
* Referenzbreite zurückfallen.
|
||||||
|
*/
|
||||||
|
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||||
|
class ResizeObserverStub implements ResizeObserver {
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverStub
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
// Die Standardgrenze von 5 s ist für diese Suite zu knapp: die Komponenten-
|
||||||
|
// tests rendern ganze Ansichten samt Porträts, und bei parallel laufenden
|
||||||
|
// Dateien kippten einzelne Tests in die Zeitüberschreitung, obwohl sie
|
||||||
|
// einzeln in Bruchteilen einer Sekunde durchlaufen.
|
||||||
|
testTimeout: 20000,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'html'],
|
reporter: ['text', 'html'],
|
||||||
|
|||||||