3 Commits

Author SHA1 Message Date
Benjamin Sutter 177641386a refactor(team): zweite Überarbeitungsrunde — Oberfläche bereinigen
Gezielte UI-Bereinigung ohne neue Architektur. Bestehende Komponenten angepasst,
Datenstrukturen und Routing unverändert.

GLOBAL
Chip «Verwaltung» und Seitenname in der Kopfzeile entfallen auf /supply/team/*;
die Seiten tragen ihren Titel bereits gross im Inhalt. Andere Module behalten die
Zeile. Demo-Modus-Hinweis und alle Untertitel entfernt. Autonomiegrad aus der
Oberfläche genommen — die Daten bleiben unangetastet.

PERSONALVERWALTUNG
Beschrieb auf zwei Sätze gekürzt, Input/Kernablauf/Output entfallen: das war
Prozessnotation, sie beschrieb wie gearbeitet wird, nicht wofür man jemanden hat.
Zuständigkeiten stehen jetzt im Kopfbereich statt tief im Dossier. Reiterfolge
neu: Agentenbeschrieb, Aufgaben, Kennzahlen, Kanäle & Systeme, Protokoll.
Kanäle und Systeme sind über eine dünne Hülle zusammengeführt — die bestehende
Logik samt Konfigurationsdialog bleibt unberührt. Aufgaben dreispaltig ohne
Zeitplan, Ereignisfilter im Protokoll entfernt.

Der abgelöste Reiter «Systeme» wird weiterhin aufgelöst, damit bestehende
Verweise nicht still auf den ersten Reiter zurückfallen.

ORGANIGRAMM — die eigentliche Korrektur
Der falsch gewählte Agent lag an der Geometrie: `rotate(a) translateY(r)` schiebt
bei a = 0 nach UNTEN, die Drehung wurde aber als `180 − index·step` berechnet.
Damit stand der gewählte Mitarbeitende oben und das Dossier darunter gehörte zum
falschen. Jetzt `−index·step`.

Dazu beruhigt: Zugempfindlichkeit von 0.5 auf 0.22 Grad je Pixel, 10 px Totzone
gegen Zittern, kurzer Zug wechselt höchstens einen Platz, Einrasten in 250 ms,
Auswahl erst nach dem Einrasten. Kreis von 720 auf 460 px.

BEARBEITUNGSVERLAUF
Filter ohne Vorgangstyp, Priorität und Kanal; Suche in Zeile eins, Rest in Zeile
zwei. Karten tragen nur noch Agent, Titel mit Liegenschaft, zwei Zeilen Text und
Zeitstempel — Priorität, Status, Vorgangstyp und Kanal standen auf praktisch
jeder Karte und trugen damit keine Information mehr.

Drawer ohne Verarbeitungsschritte und Aktionshistorie; nur die auslösende
Nachricht plus Quicklink ins Anfragencenter statt des ganzen Verlaufs.
«Fundstellen» heisst «Quellen». Der Entscheidungshinweis heisst jetzt
«Hierzu brauche ich Ihre Entscheidung». Aktionsleiste: pendent Freigeben,
Zurückweisen, Quellen anzeigen, Inserat anzeigen — erledigt nur «Objekt anzeigen».

KANÄLE & SYSTEME, TEAMÜBERSICHT
Kanäle vor Systemen als eigene Abschnitte, vier Karten pro Zeile, Beschreibung
und Statusbadge raus, Regler mit Klartext Aktiv/Inaktiv/Geplant. Die Kreisgrafik
skaliert nach Anzahl sichtbarer Ringe und ist auf die Viewport-Höhe begrenzt,
damit das Kernteam ohne Scrollen vollständig sichtbar bleibt.

Drei Testzusicherungen prüfen jetzt die Abwesenheit statt der Anwesenheit von
Objekt-ID und Rückfragegrund auf der Karte — beides wurde bewusst entfernt.

Typecheck grün, ESLint über die geänderten Dateien grün, Build grün, 413 Tests grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:30:32 +02:00
Benjamin Sutter 8377ce03b5 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>
2026-08-03 21:42:04 +02:00
Benjamin Sutter 576733d7da feat(team): Porträts der digitalen Mitarbeiter aus dem Konzept übernehmen
Bisher zeigten die Avatare Initialen auf einer Tonfarbe. Ich hatte das bewusst
so gebaut, weil erfundene Gesichter in einer Kundendemo irreführend wären — das
Konzeptdokument «Agenten_Bewirtschaftung_v2.html» trifft diese Entscheidung
jedoch bereits: dort hängt an jedem der 36 Agenten ein Porträt als eingebettetes
JPEG. Die sieben Kernagenten sind daraus extrahiert.

- src/assets/team/*.jpg — sieben Porträts, je rund 6 KB, Zuordnung über die
  Mailadresse aus dem Konzept, die exakt unseren Agenten-IDs entspricht
- agentPhotos.ts hält die Zuordnung in der Darstellungsschicht. Ein Domain-Typ,
  der Binärdateien importiert, wäre an den Bundler gekoppelt und weder im Test
  noch später gegen ein echtes Backend sauber verwendbar.
- Fehlt zu einer ID ein Bild, zeigt der Avatar weiterhin die Initialen; dasselbe
  greift, wenn das Laden fehlschlägt

Der zugängliche Name hängt weiterhin an genau einem Element, nie an zweien: mit
Porträt am erzeugten <img> über `alt`, ohne Porträt am Container über role="img"
und aria-label.

vite-env.d.ts ergänzt — die Referenz auf vite/client fehlte, ohne sie kennt
TypeScript die Modul-Deklarationen für Assets nicht.

415 Tests grün, Build grün, die sieben JPEGs werden korrekt als Assets emittiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:23:03 +02:00
79 changed files with 2857 additions and 1062 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+27 -18
View File
@@ -27,6 +27,11 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
const pageName = getPageNameFromPath(pathname)
const openAssistant = useAssistantStore(s => s.open)
// Die Property-On-Seiten tragen ihren Titel bereits gross im Inhalt. Das Chip
// «Verwaltung» plus derselbe Seitenname darüber wäre eine doppelte Angabe und
// kostet nur vertikale Höhe. Andere Module behalten die Zeile unverändert.
const showBreadcrumb = !pathname.startsWith('/supply/team')
return (
<Box
component="header"
@@ -48,24 +53,28 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Menu size={20} />
</IconButton>
)}
<Chip
label={config.label}
size="small"
variant="outlined"
sx={{
color: '#152642',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
border: '1px solid #e8e7e4',
}}
/>
<Typography
variant="body1"
sx={{ fontWeight: 500, color: '#1e293b', fontSize: { xs: '0.85rem', sm: '0.9375rem' } }}
>
{pageName}
</Typography>
{showBreadcrumb && (
<>
<Chip
label={config.label}
size="small"
variant="outlined"
sx={{
color: '#152642',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
border: '1px solid #e8e7e4',
}}
/>
<Typography
variant="body1"
sx={{ fontWeight: 500, color: '#1e293b', fontSize: { xs: '0.85rem', sm: '0.9375rem' } }}
>
{pageName}
</Typography>
</>
)}
</Box>
{/* Right side */}
+57 -26
View File
@@ -1,66 +1,97 @@
import { memo } from 'react'
import { Avatar, Box, Tooltip } from '@mui/material'
import { Check } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentStatus } from '../../domain/teamAgent'
import type { AgentAvatarSubject } from '../../domain/agentDirectory'
import type { AgentStatus, AgentAvatarTone } from '../../domain/teamAgent'
import { AgentStatus as Status } from '../../domain/teamAgent'
import { AVATAR_TONE_COLOR } from './teamTokens'
import { AGENT_PHOTOS } from './agentPhotos'
import { AGENT_STATUS_LABELS } from '../../lib/constants'
import { DS_BG, DS_TEXT } from '../../lib/ds'
import { DS_BG, DS_TEXT, DS_BORDER } from '../../lib/ds'
const SIZES = {
const NAMED_SIZES = {
xsmall: 22,
small: 28,
medium: 40,
large: 64,
xlarge: 96,
} as const
export type AgentAvatarSize = keyof typeof SIZES
export type AgentAvatarSize = keyof typeof NAMED_SIZES | number
interface Props {
agent: TeamAgent
size?: AgentAvatarSize
/**
* Blendet den Statuspunkt ein. Auf der Teamübersicht ist er verbindlich (§6.2):
* ein grüner Haken für aktiv, sonst der Hinweis «Pausiert».
* Bewusst die Minimalform: so passt sowohl ein vollständiger `TeamAgent` als
* auch ein blosser Verzeichniseintrag hinein. Zwei fast identische
* Avatarkomponenten für dieselbe Sache wären reine Duplikation.
*/
agent: AgentAvatarSubject
size?: AgentAvatarSize
/** Nur die sieben Kernteammitglieder führen einen Betriebszustand. */
status?: AgentStatus
showStatus?: boolean
/** Farbe der Initialen-Fläche, falls kein Porträt vorhanden ist. */
tone?: AgentAvatarTone
/** Ringe mit vielen Porträts laden verzögert — nur sichtbare zuerst. */
loading?: 'lazy' | 'eager'
/** Zusätzlicher Rahmen, etwa zur Hervorhebung des Kernteams. */
ringColor?: string
ringWidth?: number
}
function initialsOf(name: string): string {
return name.trim().slice(0, 2).toUpperCase()
}
/**
* Es gibt keine Porträtbilder für digitale Mitarbeiter — und erfundene
* Gesichter wären in einer Kundendemo irreführend. Stattdessen Initialen auf
* einer je Mitarbeiter festen Farbe.
*/
export const AgentAvatar = memo(function AgentAvatar({ agent, size = 'medium', showStatus = false }: Props) {
const px = SIZES[size]
const isActive = agent.status === AgentStatus.ACTIVE
const statusLabel = AGENT_STATUS_LABELS[agent.status] ?? agent.status
const badgePx = size === 'large' ? 20 : 14
function resolveSize(size: AgentAvatarSize): number {
return typeof size === 'number' ? size : NAMED_SIZES[size]
}
export const AgentAvatar = memo(function AgentAvatar({
agent,
size = 'medium',
status,
showStatus = false,
tone,
loading = 'lazy',
ringColor,
ringWidth = 2,
}: Props) {
const px = resolveSize(size)
const photo = AGENT_PHOTOS[agent.id]
const label = `${agent.name}, ${agent.role}`
const isActive = status === Status.ACTIVE
const statusLabel = status ? (AGENT_STATUS_LABELS[status] ?? status) : ''
const badgePx = px >= 64 ? 20 : 14
return (
<Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}>
{/* `alt` reicht MUI ausschliesslich an den img-Slot weiter, und ohne `src`
rendert Avatar gar kein <img> — die Beschriftung landete damit nirgends
im DOM. Der zugängliche Name muss deshalb direkt auf das Element. */}
{/* Der zugängliche Name hängt an genau einem Element, nie an zweien:
mit Porträt am erzeugten <img> über `alt`, ohne Porträt am Container
über role="img" und aria-label. `alt` allein genügt nämlich nicht —
ohne `src` rendert MUI gar kein <img>, der Wert landet nirgends. */}
<Avatar
role="img"
aria-label={`${agent.name}, ${agent.role}`}
src={photo}
alt={photo ? label : undefined}
slotProps={{ img: { loading } }}
{...(photo ? {} : { role: 'img', 'aria-label': label })}
sx={{
width: px,
height: px,
bgcolor: AVATAR_TONE_COLOR[agent.avatarTone],
bgcolor: tone ? AVATAR_TONE_COLOR[tone] : DS_BORDER.strong,
color: DS_TEXT.inverted,
fontSize: px * 0.38,
fontWeight: 700,
...(ringColor
? { border: `${ringWidth}px solid ${ringColor}`, boxSizing: 'border-box' }
: {}),
}}
>
{/* Fällt das Bild aus, zeigt MUI wieder die Initialen statt eines Lochs. */}
{initialsOf(agent.name)}
</Avatar>
{showStatus && (
{showStatus && status && (
<Tooltip title={statusLabel} arrow>
<Box
aria-label={statusLabel}
+108
View File
@@ -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>
)
})
-98
View File
@@ -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,54 @@
import { Box } from '@mui/material'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentChannelsTab } from './AgentChannelsTab'
import { AgentSystemsTab } from './AgentSystemsTab'
import { DS_TEXT } from '../../lib/ds'
import { Typography } from '@mui/material'
/**
* Zusammengeführter Reiter «Kanäle & Systeme».
*
* Bewusst eine dünne Hülle über die beiden bestehenden Reiter statt einer
* Neufassung: die Kanal- und Systemlogik samt Konfigurationsdialog bleibt
* unangetastet, es ändert sich nur, dass beides auf einer Fläche steht.
* Reihenfolge wie vorgegeben — zuerst Kanäle, darunter Systeme.
*/
export function AgentConnectionsTab({ agent }: { agent: TeamAgent }) {
return (
<Box sx={{ display: 'grid', gap: 4 }}>
<Box component="section" aria-label="Kanäle">
<Typography
component="h3"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
mb: 1.5,
}}
>
Kanäle
</Typography>
<AgentChannelsTab agent={agent} />
</Box>
<Box component="section" aria-label="Systeme">
<Typography
component="h3"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
mb: 1.5,
}}
>
Systeme
</Typography>
<AgentSystemsTab agent={agent} />
</Box>
</Box>
)
}
@@ -0,0 +1,59 @@
/**
* Property On — Reiter «Agentenbeschrieb» im Personaldossier.
*
* Bewusst nur ein kurzer Absatz. Die frühere Dreiteilung aus Input, Kernablauf
* und Output war eine Prozessnotation — sie beschrieb, wie der Mitarbeitende
* arbeitet, nicht wofür man ihn hat. Die Zuständigkeiten stehen jetzt im
* Kopfbereich, die Kennzahlen im eigenen Reiter.
*
* Es wird nichts formuliert, was nicht in den Daten steht: beide Sätze stammen
* unverändert aus dem Personalblatt. Sagen sie dasselbe, bleibt einer stehen.
*/
import { useMemo } from 'react'
import { Box, Typography } from '@mui/material'
import type { TeamAgent } from '../../domain/teamAgent'
import { DS_TEXT } from '../../lib/ds'
/**
* Doppelung erkennen, ohne Text zu erfinden: Kurze Wörter tragen im Deutschen
* kaum Bedeutung («und», «bis», «der»), deshalb zählen nur längere.
*/
const MIN_WORD_LENGTH = 5
const REDUNDANCY_THRESHOLD = 0.6
function contentWords(text: string): Set<string> {
return new Set(
text.toLowerCase().split(/[^a-zäöüß]+/).filter(w => w.length >= MIN_WORD_LENGTH),
)
}
function saysTheSame(a: string, b: string): boolean {
const left = contentWords(a)
if (left.size === 0) return false
const right = contentWords(b)
let shared = 0
for (const word of left) if (right.has(word)) shared += 1
return shared / left.size >= REDUNDANCY_THRESHOLD
}
export function AgentDescriptionTab({ agent }: { agent: TeamAgent }) {
const sentences = useMemo(() => {
const first = agent.shortDescription.trim()
const second = agent.profile.purpose.trim()
return saysTheSame(first, second) ? [first] : [first, second]
}, [agent.shortDescription, agent.profile.purpose])
return (
<Box sx={{ maxWidth: '78ch', display: 'grid', gap: 1 }}>
{sentences.map((sentence) => (
<Typography
key={sentence}
sx={{ color: DS_TEXT.primary, fontSize: '0.9375rem', lineHeight: 1.65 }}
>
{sentence}
</Typography>
))}
</Box>
)
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 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 { AgentMetricsTab } from './AgentMetricsTab'
import { AgentConnectionsTab } from './AgentConnectionsTab'
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: 'kennzahlen', label: 'Kennzahlen' },
{ segment: 'kanaele', label: 'Kanäle & Systeme' },
{ segment: 'protokoll', label: 'Protokoll' },
// Einstellungen steht nicht in der neuen Reihenfolge, bleibt aber erhalten:
// Meldeschwellen, Quellenzwang und Freigaberegeln sind fachlich notwendig und
// haben sonst keinen Ort in der Oberfläche.
{ segment: 'einstellungen', label: 'Einstellungen' },
] as const
export type DossierSegment = typeof DOSSIER_TABS[number]['segment']
export const DEFAULT_DOSSIER_SEGMENT: DossierSegment = 'beschrieb'
/**
* Der frühere eigene Reiter «Systeme» ist in «Kanäle & Systeme» aufgegangen.
* Bestehende Verweise auf das alte Segment führen weiterhin ans Ziel, statt
* still auf den ersten Reiter zurückzufallen.
*/
const LEGACY_SEGMENTS: Record<string, DossierSegment> = { systeme: 'kanaele' }
// 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)
}
/** Löst ein Segment aus der Adresse auf, inklusive der abgelösten Schreibweise. */
// eslint-disable-next-line react-refresh/only-export-components -- siehe DOSSIER_TABS
export function resolveDossierSegment(value: string | undefined): DossierSegment {
if (isDossierSegment(value)) return value
if (value && LEGACY_SEGMENTS[value]) return LEGACY_SEGMENTS[value]
return DEFAULT_DOSSIER_SEGMENT
}
/** 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 === 'kennzahlen' && <AgentMetricsTab key={agent.id} agent={agent} />}
{activeSegment === 'kanaele' && <AgentConnectionsTab key={agent.id} agent={agent} />}
{activeSegment === 'protokoll' && <AgentProtocolTab key={agent.id} agent={agent} />}
{activeSegment === 'einstellungen' && <AgentSettingsTab key={agent.id} agent={agent} />}
</Box>
</>
)
}
+44 -4
View File
@@ -4,7 +4,7 @@ import { AlertTriangle, Mail } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentStatus } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar'
import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges'
import { AgentStatusBadge } from './AgentBadges'
import { ConfirmDialog } from '../ui'
import { useSetAgentActive } from '../../hooks/useTeamAgents'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
@@ -14,6 +14,22 @@ interface Props {
agent: TeamAgent
}
/** Mehr als vier Stichworte liest im Kopfbereich niemand mehr. */
const MAX_RESPONSIBILITIES = 4
const LABEL_MAX_CHARS = 42
/**
* Verdichtet eine ausformulierte Zuständigkeit auf ein Stichwort.
* Schneidet am ersten Komma, sonst an der Wortgrenze — der volle Wortlaut
* bleibt als Titel am Element erhalten, es geht also keine Aussage verloren.
*/
function shortLabel(text: string): string {
const head = text.split(',')[0].trim()
if (head.length <= LABEL_MAX_CHARS) return head
const cut = head.slice(0, LABEL_MAX_CHARS)
return `${cut.slice(0, cut.lastIndexOf(' '))}`
}
function MetaField({ label, value }: { label: string; value: string }) {
return (
<Box>
@@ -48,7 +64,7 @@ export function AgentDossierHeader({ agent }: Props) {
return (
<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' }}>
<AgentAvatar agent={agent} size="large" showStatus />
<AgentAvatar agent={agent} size="large" status={agent.status} tone={agent.avatarTone} showStatus />
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.25rem', color: DS_TEXT.primary, lineHeight: 1.25 }}>
@@ -65,8 +81,33 @@ export function AgentDossierHeader({ agent }: Props) {
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
<AgentStatusBadge status={agent.status} />
<AgentAutonomyBadge autonomy={agent.autonomyLevel} note={agent.autonomyNote} />
</Box>
{/* Kurzer Beschrieb und Zuständigkeiten stehen jetzt oben statt tief im
Dossier — das ist die Frage, die man beim Öffnen zuerst hat. */}
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 1.25, maxWidth: '70ch' }}>
{agent.shortDescription}
</Typography>
{agent.responsibilities.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap', mt: 1.25 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, flexShrink: 0 }}>
Zuständig für:
</Typography>
{agent.responsibilities.slice(0, MAX_RESPONSIBILITIES).map((r, i) => (
<Box key={r} sx={{ display: 'inline-flex', alignItems: 'baseline', gap: 1 }}>
{i > 0 && <Typography aria-hidden variant="caption" sx={{ color: DS_TEXT.disabled }}>·</Typography>}
<Typography
title={r}
variant="caption"
sx={{ color: DS_TEXT.secondary, fontWeight: 600 }}
>
{shortLabel(r)}
</Typography>
</Box>
))}
</Box>
)}
</Box>
<FormControlLabel
@@ -96,7 +137,6 @@ export function AgentDossierHeader({ agent }: Props) {
>
<MetaField label="Personalnummer" value={agent.personnelNumber} />
<MetaField label="Abteilung" value={agent.department} />
<MetaField label="Autonomiegrad" value={agent.autonomyNote} />
<MetaField
label="Letzter Lauf"
value={agent.lastRun ? formatTeamDateTime(agent.lastRun) : 'Noch nicht gelaufen'}
+1 -1
View File
@@ -35,7 +35,7 @@ const AgentListRow = memo(function AgentListRow({ agent, selected, onSelect }: R
'&: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 }}>
<Typography sx={{ fontWeight: selected ? 700 : 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
{agent.name}
+71
View File
@@ -0,0 +1,71 @@
import { memo } from 'react'
import { Box, Typography } from '@mui/material'
import type { AgentMetric, TeamAgent } from '../../domain/teamAgent'
import { EmptyState } from '../ui'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
/**
* Reiter «Kennzahlen».
*
* Zeigt ausschliesslich die Kennzahlen, die im Personalblatt des Mitarbeitenden
* bereits hinterlegt sind. Es werden keine Werte berechnet, hochgerechnet oder
* ergänzt — was der Katalog nicht führt, steht hier auch nicht.
*/
const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric }) {
return (
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 2,
minWidth: 0,
}}
>
<Typography
sx={{
fontWeight: 700,
fontSize: '1.5rem',
lineHeight: 1.15,
color: DS_TEXT.primary,
fontVariantNumeric: 'tabular-nums',
}}
>
{metric.value}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600, mt: 0.5 }}>
{metric.label}
</Typography>
{metric.hint && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mt: 0.25 }}>
{metric.hint}
</Typography>
)}
</Box>
)
})
export function AgentMetricsTab({ agent }: { agent: TeamAgent }) {
if (agent.metrics.length === 0) {
return (
<EmptyState
title="Keine Kennzahlen hinterlegt"
description={`Für ${agent.name} führt das Personalblatt derzeit keine Kennzahlen.`}
/>
)
}
return (
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' },
}}
>
{agent.metrics.map((metric) => (
<MetricTile key={metric.id} metric={metric} />
))}
</Box>
)
}
@@ -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>
)
}
+2 -9
View File
@@ -13,7 +13,7 @@ import { Box, Button, Collapse, FormControlLabel, InputAdornment, MenuItem, Skel
import { AlertTriangle, Building2, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Info, Radio, Search, UserRound, XCircle } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import type { AgentProtocolEntry } from '../../domain/agentProtocol'
import { AgentProtocolEventType, AgentProtocolStatus, AgentTriggerSource } from '../../domain/agentProtocol'
import { AgentProtocolStatus, AgentTriggerSource } from '../../domain/agentProtocol'
import { AgentPeriod } from '../../domain/agentFilters'
import type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider'
import { useAgentProtocol } from '../../hooks/useAgentProtocol'
@@ -145,7 +145,6 @@ const ProtocolEntryRow = memo(function ProtocolEntryRow({ entry, expanded, isLas
export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
const [period, setPeriod] = useState<AgentPeriod>(AgentPeriod.ALL)
const [eventType, setEventType] = useState<AgentProtocolEventType | 'ALL'>('ALL')
const [status, setStatus] = useState<AgentProtocolStatus | 'ALL'>('ALL')
const [search, setSearch] = useState('')
const [onlyApprovals, setOnlyApprovals] = useState(false)
@@ -154,11 +153,10 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
const filters = useMemo<AgentProtocolFilters>(() => ({
agentId: agent.id,
period,
eventType: eventType === 'ALL' ? undefined : eventType,
status: status === 'ALL' ? undefined : status,
onlyApprovals: onlyApprovals || undefined,
search: search.trim() || undefined,
}), [agent.id, period, eventType, status, onlyApprovals, search])
}), [agent.id, period, status, onlyApprovals, search])
const { data: entries = [], isLoading, isError, refetch } = useAgentProtocol(filters)
const lastId = entries.length > 0 ? entries[entries.length - 1].id : null
@@ -167,7 +165,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
const resetFilters = useCallback(() => {
setPeriod(AgentPeriod.ALL)
setEventType('ALL')
setStatus('ALL')
setSearch('')
setOnlyApprovals(false)
@@ -190,10 +187,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
labels={AGENT_PERIOD_LABELS} allLabel={AGENT_PERIOD_LABELS.ALL}
onChange={(v) => setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)}
/>
<FilterSelect
label="Ereignisart" value={eventType} options={Object.values(AgentProtocolEventType)}
labels={AGENT_PROTOCOL_EVENT_LABELS} allLabel="Alle Ereignisarten" onChange={setEventType}
/>
<FilterSelect
label="Status" value={status} options={Object.values(AgentProtocolStatus)}
labels={AGENT_PROTOCOL_STATUS_LABELS} allLabel="Alle Status" onChange={setStatus}
+93
View File
@@ -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>
)
})
+128
View File
@@ -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>
)
})
+37 -36
View File
@@ -10,9 +10,8 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { Box, Button, Switch, Typography } from '@mui/material'
import { Clock, Plug, RotateCcw, Save, ShieldCheck } from 'lucide-react'
import { RotateCcw, Save, ShieldCheck } from 'lucide-react'
import type { AgentTask, TeamAgent } from '../../domain/teamAgent'
import { AGENT_CHANNEL_LABELS, AGENT_SYSTEM_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { GenericBadge } from '../shared/GenericBadge'
import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
@@ -30,17 +29,6 @@ interface TaskRowProps {
* jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2).
*/
const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
const dependencies = useMemo(() => {
const out: string[] = []
if (task.dependsOnChannel) {
out.push(`Kanal: ${AGENT_CHANNEL_LABELS[task.dependsOnChannel] ?? task.dependsOnChannel}`)
}
if (task.dependsOnSystem) {
out.push(`Systemzugang: ${AGENT_SYSTEM_LABELS[task.dependsOnSystem] ?? task.dependsOnSystem}`)
}
return out
}, [task.dependsOnChannel, task.dependsOnSystem])
const handleChange = useCallback(
(_event: unknown, checked: boolean) => onToggle(task.id, checked),
[onToggle, task.id],
@@ -51,9 +39,9 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
component="li"
sx={{
display: 'flex',
gap: 1.5,
gap: 1,
alignItems: 'flex-start',
p: 1.75,
p: 1.25,
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: task.enabled ? DS_BG.surface : DS_BG.subtle,
@@ -61,7 +49,7 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
}}
>
{/* Schalter mit Zustand als Text — Farbe allein trägt keinen Status (§18) */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, width: 76 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, width: 60 }}>
<Switch
checked={task.enabled}
disabled={busy}
@@ -83,32 +71,30 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary }}>
{task.title}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
{/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle
Wortlaut steht am Element. */}
<Typography
title={task.description}
variant="body2"
sx={{
color: DS_TEXT.secondary,
fontSize: '0.8125rem',
mt: 0.25,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{task.description}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}>
<Clock size={13} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Zeitplan: {task.schedule}
</Typography>
</Box>
{dependencies.map((dependency) => (
<Box key={dependency} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.375 }}>
<Plug size={13} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Setzt voraus {dependency}
</Typography>
</Box>
))}
{task.requiresApproval && (
<Box sx={{ mt: 1 }}>
<Box sx={{ mt: 0.75 }}>
<GenericBadge
label="Freigabe erforderlich"
semanticVariant="confidenceMedium"
@@ -224,7 +210,22 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
Für {agent.name} sind noch keine Aufgaben hinterlegt.
</Typography>
) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, mt: 1.75, p: 0, display: 'grid', gap: 1.25 }}>
<Box
component="ul"
sx={{
listStyle: 'none',
m: 0,
mt: 1.75,
p: 0,
display: 'grid',
gap: 1.25,
gridTemplateColumns: {
xs: '1fr',
md: 'repeat(2, minmax(0, 1fr))',
xl: 'repeat(3, minmax(0, 1fr))',
},
}}
>
{draft.map((task) => (
<TaskRow key={task.id} task={task} busy={busy} onToggle={handleToggle} />
))}
+141 -88
View File
@@ -1,20 +1,81 @@
import { memo } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { Plug, PlugZap, Unplug, Users } from 'lucide-react'
import type { AgentConnection } from '../../domain/agentConnection'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentConnectionStatus } from '../../domain/teamAgent'
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
/**
* Property On — Karte einer zentralen Verbindung (§13).
*
* Verbunden oder nicht verbunden ist ein Zweizustand, also trägt ihn ein Regler
* und keine Knopfreihe: einschalten öffnet den Verbindungsassistenten,
* ausschalten löst die Trennung aus, die der Aufrufer weiterhin bestätigen
* lässt. «Verbindung testen» bleibt ein Knopf — das ist keine Zustandsänderung.
*
* Markenlogos gibt es im Projekt nicht und werden auch nicht nachgezeichnet.
* Stattdessen trägt jede Kategorie ein neutrales, einheitliches Sinnbild.
*/
import { memo, useCallback } from 'react'
import { Box, Button, Switch, Typography } from '@mui/material'
import {
AGENT_CONNECTION_CATEGORY_LABELS,
AGENT_ERP_VENDOR_LABELS,
} from '../../lib/constants'
Calendar,
Contact,
Database,
FolderOpen,
Globe,
Mail,
MessageCircle,
Phone,
Users,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { AgentConnection } from '../../domain/agentConnection'
import { AgentConnectionCategory } from '../../domain/agentConnection'
import { AgentConnectionStatus } from '../../domain/teamAgent'
import { AgentAvatarGroup } from './AgentAvatarGroup'
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
import { formatTeamRelative } from '../../lib/teamClock'
const CATEGORY_ICON: Record<AgentConnectionCategory, 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,
}
/**
* 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 {
connection: AgentConnection
agents: TeamAgent[]
busy: boolean
onConfigure: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
@@ -23,7 +84,6 @@ interface Props {
export const ConnectionCard = memo(function ConnectionCard({
connection,
agents,
busy,
onConfigure,
onDisconnect,
@@ -31,10 +91,19 @@ export const ConnectionCard = memo(function ConnectionCard({
}: Props) {
const isConnected = connection.status === AgentConnectionStatus.CONNECTED
const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP
const users = connection.usedByAgentIds
.map(id => agents.find(a => a.id === id)?.name ?? id)
.join(', ')
const grantedPermissions = connection.permissions.filter(p => p.granted)
const handleToggle = useCallback(() => {
if (isConnected) onDisconnect(connection.id)
else onConfigure(connection.id)
}, [isConnected, onDisconnect, onConfigure, connection.id])
const handleDetails = useCallback(() => onConfigure(connection.id), [onConfigure, connection.id])
const handleTest = useCallback(() => onTest(connection.id), [onTest, connection.id])
// Neun Regler auf einer Seite: der Name muss im Bedienhilfe-Text mitkommen.
const switchLabel = isRoadmap
? `${connection.name}: geplant, noch nicht verfügbar`
: `${connection.name} ${isConnected ? 'trennen' : 'verbinden'}`
return (
<Box
@@ -44,103 +113,87 @@ export const ConnectionCard = memo(function ConnectionCard({
borderRadius: 2,
bgcolor: DS_BG.surface,
boxShadow: DS_SHADOW.card,
p: 2,
p: 1.5,
display: 'flex',
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: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{connection.name}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.category}
{connection.vendor ? ` · ${AGENT_ERP_VENDOR_LABELS[connection.vendor]}` : ''}
<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',
}}
>
{connection.name}
</Typography>
{/* Zustand als Text neben dem Regler — Farbe allein trägt keinen Status. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: isConnected ? DS_TEXT.success : DS_TEXT.muted }}
>
{isRoadmap ? 'Geplant' : isConnected ? 'Aktiv' : 'Inaktiv'}
</Typography>
<Switch
size="small"
checked={isConnected}
disabled={busy || isRoadmap}
onChange={handleToggle}
slotProps={{ input: { 'aria-label': switchLabel } }}
/>
</Box>
<AgentConnectionStatusBadge status={connection.status} />
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, flex: 1 }}>
{connection.description}
</Typography>
{connection.connectionLabel && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Verbindung: «{connection.connectionLabel}»
</Typography>
)}
<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>
<AgentAvatarGroup agentIds={connection.usedByAgentIds} label="Genutzt durch" size={20} />
<Box
sx={{
mt: 'auto',
pt: 0.75,
borderTop: `1px solid ${DS_BORDER.muted}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{connection.lastSyncAt
? `Letzte Synchronisation ${formatTeamDateTime(connection.lastSyncAt)}`
? `Synchronisiert ${formatTeamRelative(connection.lastSyncAt)}`
: 'Noch keine Synchronisation'}
</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 && (
<>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Button
size="small"
disabled={busy}
onClick={() => onTest(connection.id)}
sx={{ textTransform: 'none', fontWeight: 600 }}
onClick={handleTest}
aria-label={`${connection.name}: Verbindung testen`}
sx={{ textTransform: 'none', fontWeight: 600, minWidth: 0, px: 0.75 }}
>
Verbindung testen
</Button>
<Button
size="small"
color="error"
disabled={busy}
startIcon={<Unplug size={14} />}
onClick={() => onDisconnect(connection.id)}
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
onClick={handleDetails}
aria-label={`${connection.name}: Details und Berechtigungen`}
sx={{ textTransform: 'none', fontWeight: 600, minWidth: 0, px: 0.75 }}
>
Trennen
Details
</Button>
</>
)}
{isRoadmap && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, alignSelf: 'center' }}>
Diese Anbindung ist geplant.
</Typography>
</Box>
)}
</Box>
</Box>
+70 -41
View File
@@ -1,66 +1,95 @@
import { memo } from 'react'
import { Box, Typography } from '@mui/material'
import type { AgentConnection } from '../../domain/agentConnection'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentConnectionStatusBadge } from './AgentBadges'
import { AgentAvatarGroup } from './AgentAvatarGroup'
import { ConnectionCategoryIcon } from './ConnectionCard'
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
import { formatTeamRelative } from '../../lib/teamClock'
interface Props {
connections: AgentConnection[]
agents: TeamAgent[]
/**
* Wird nicht mehr benötigt: die Porträtgruppe löst die Namen selbst aus dem
* Verzeichnis auf. Bleibt optional erhalten, damit bestehende Aufrufer
* unverändert weiterlaufen.
*/
agents?: TeamAgent[]
}
/**
* Kompakte Darstellung der Verbindungen auf der Teamübersicht (§6.6).
* Dieselben fachlichen Inhalte wie auf der vollständigen Seite «Kanäle & Systeme»,
* nur ohne Aktionen verbunden wird dort, nicht hier.
* Dasselbe Kartenmuster wie auf «Kanäle & Systeme» — Sinnbild, Name, Zustand,
* Porträtgruppe —, nur ohne Aktionen: verbunden wird dort, nicht hier.
*/
export function ConnectionSummaryList({ connections, agents }: Props) {
const nameById = new Map(agents.map(a => [a.id, a.name]))
export function ConnectionSummaryList({ connections }: Props) {
return (
<Box
sx={{
display: 'grid',
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) => {
const users = connection.usedByAgentIds.map(id => nameById.get(id) ?? id)
return (
<Box
key={connection.id}
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
{AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name}
</Typography>
<AgentConnectionStatusBadge status={connection.status} />
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{users.length > 0 ? `Genutzt von ${users.join(', ')}` : 'Noch keinem Mitarbeiter zugewiesen'}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{connection.lastSyncAt
? `Zuletzt abgeglichen ${formatTeamRelative(connection.lastSyncAt)}`
: 'Noch kein Abgleich'}
</Typography>
</Box>
)
})}
{connections.map((connection) => (
<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 (
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 1.25,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ConnectionCategoryIcon category={connection.category} size={24} />
<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>
<AgentConnectionStatusBadge status={connection.status} />
</Box>
<AgentAvatarGroup agentIds={connection.usedByAgentIds} label="Genutzt durch" size={20} />
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{connection.lastSyncAt
? `Zuletzt abgeglichen ${formatTeamRelative(connection.lastSyncAt)}`
: 'Noch kein Abgleich'}
</Typography>
</Box>
)
})
@@ -189,7 +189,7 @@ export function StepAgents({ agents, draft, onChange }: StepProps) {
onChange={() => toggle(agent.id)}
slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }}
/>
<AgentAvatar agent={agent} size="small" />
<AgentAvatar agent={agent} size="small" tone={agent.avatarTone} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
{agent.name}
+215
View File
@@ -0,0 +1,215 @@
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. Bewusst träge — ein Wisch soll nicht durchs Team jagen. */
const DRAG_DEGREES_PER_PX = 0.22
/** Darunter gilt eine Bewegung als Zittern und wird verworfen. */
const DRAG_THRESHOLD_PX = 10
/** Bis zu dieser Zugweite wechselt höchstens ein Platz. */
const SINGLE_STEP_LIMIT_PX = 90
/** Durchmesser des gedachten Vollkreises; sichtbar ist nur die untere Hälfte. */
const WHEEL_DIAMETER = 460
const AVATAR_PX = 52
const VISIBLE_HEIGHT = WHEEL_DIAMETER / 2 + 34
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
/**
* Drehbares Agentenrad der Organigramm-Ansicht.
*
* Geometrie: `rotate(a) translateY(r)` schiebt ein Element bei a = 0 nach UNTEN.
* Ein Knoten mit Grundwinkel `i · step` steht also genau dann unten in der Mitte,
* wenn das Rad um `i · step` gedreht ist. Genau das war der Fehler zuvor: mit
* `180 i · step` landete der gewählte Mitarbeitende oben, das Dossier darunter
* gehörte folglich zum falschen Agenten.
*
* Der gedachte Vollkreis ragt nach oben aus dem Container heraus und wird von
* ihm abgeschnitten — es wird nichts am Bild zugeschnitten, nur beschnitten.
*
* Während des Ziehens läuft ausschliesslich 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)
const [isDragging, setIsDragging] = useState(false)
const drag = useRef<{ startX: number } | null>(null)
// Drehung, die den gewählten Mitarbeitenden nach unten in die Mitte bringt.
const baseDeg = -selectedIndex * step
const rotation = baseDeg + dragDeg
const angles = useMemo(() => agents.map((_, i) => i * step), [agents, step])
/** Wie viele Plätze eine Zugweite bedeutet — kurze Züge höchstens einen. */
const stepsFor = useCallback(
(dx: number) => {
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return 0
const raw = -(dx * DRAG_DEGREES_PER_PX) / step
const rounded = Math.round(raw)
if (Math.abs(dx) <= SINGLE_STEP_LIMIT_PX) return clamp(rounded, -1, 1)
return rounded
},
[step],
)
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
drag.current = { startX: e.clientX }
setIsDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!drag.current) return
setDragDeg((e.clientX - drag.current.startX) * DRAG_DEGREES_PER_PX)
}, [])
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!drag.current) return
const dx = e.clientX - drag.current.startX
drag.current = null
setIsDragging(false)
setDragDeg(0)
const moved = stepsFor(dx)
if (moved === 0 || agents.length === 0) return
const next = agents[((selectedIndex + moved) % agents.length + agents.length) % agents.length]
if (next && next.id !== selectedId) onSelect(next.id)
},
[stepsFor, agents, selectedIndex, selectedId, onSelect],
)
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"
aria-activedescendant={active ? `wheel-${active.id}` : undefined}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
sx={{
position: 'relative',
height: VISIBLE_HEIGHT,
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%',
top: -WHEEL_DIAMETER / 2 + 22,
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 250ms ease-out',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
>
{agents.map((agent, index) => {
const angle = angles[index]
const isActive = index === selectedIndex
const size = isActive ? AVATAR_PX + 12 : AVATAR_PX
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 - 6}px) rotate(${-angle - rotation}deg) translate(-50%, -50%)`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.4,
width: 132,
cursor: 'pointer',
opacity: isActive ? 1 : 0.72,
}}
>
<AgentAvatar
agent={agent}
size={size}
loading="eager"
ringColor={isActive ? BADGE_COLORS.gold : DS_BORDER.default}
ringWidth={isActive ? 3 : 1.5}
/>
<Typography
sx={{
fontWeight: isActive ? 700 : 600,
fontSize: isActive ? '0.875rem' : '0.75rem',
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', lineHeight: 1.2 }}>
{agent.role}
</Typography>
)}
</Box>
)
})}
</Box>
<Typography
variant="caption"
sx={{ position: 'absolute', left: 16, bottom: 8, color: DS_TEXT.muted }}
>
Ziehen zum Wechseln
</Typography>
</Box>
)
}
+71
View File
@@ -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>
)
}
+190
View File
@@ -0,0 +1,190 @@
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
}
/** Verkleinerung nach Anzahl sichtbarer Ringe. */
const RING_COUNT_SCALE: Record<number, number> = { 1: 0.72, 2: 0.84, 3: 0.92, 4: 1 }
/** Platz für Seitentitel, Stufenfilter und Aussenabstände. */
const VIEWPORT_RESERVE_PX = 250
/** 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)
/**
* Je mehr Ringe sichtbar sind, desto kleiner die Grafik: sonst wächst sie mit
* jeder Stufe aus dem Bild heraus und die Seite wird länger. Zusätzlich
* begrenzt die Viewport-Höhe, damit das Kernteam ohne Scrollen vollständig
* sichtbar bleibt.
*/
const ringScale = RING_COUNT_SCALE[visibleUpTo + 1] ?? RING_COUNT_SCALE[4]
const boxSize = `min(100%, ${Math.round(RING_REFERENCE_PX * ringScale)}px, calc(100vh - ${VIEWPORT_RESERVE_PX}px))`
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: boxSize,
aspectRatio: '1 / 1',
mx: 'auto',
transition: 'width 0.35s ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
>
{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} />
</>
)
}
-8
View File
@@ -1,7 +1,5 @@
import type { ReactNode } from 'react'
import { Box, Typography } from '@mui/material'
import { Info } from 'lucide-react'
import { AGENT_DEMO_NOTICE } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
interface Props {
@@ -57,12 +55,6 @@ export function TeamPageHeader({ title, description, actions, tabs }: Props) {
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.75 }}>
<Info size={13} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{AGENT_DEMO_NOTICE}
</Typography>
</Box>
</Box>
{actions && (
+85 -169
View File
@@ -1,53 +1,39 @@
import { useState } from 'react'
import { Box, Button } from '@mui/material'
import { Check, CheckCheck, FileSearch, MessageSquareReply, Pencil, Scale, X } from 'lucide-react'
import type { AgentWorkItem, AgentEditableField } from '../../domain/agentWorkItem'
import { AgentWorkItemAction } from '../../domain/agentWorkItem'
import { Building2, Check, FileSearch, X } from 'lucide-react'
import type { AgentWorkItem } from '../../domain/agentWorkItem'
import { ConfirmDialog } from '../ui'
import { RejectWorkItemDialog, EditWorkItemDialog, AnswerQueryDialog } from './WorkItemActionDialogs'
import {
useApproveWorkItem,
useRejectWorkItem,
useSaveWorkItemEdit,
useAnswerWorkItemQuery,
useMarkWorkItemDone,
} from '../../hooks/useAgentWorkItems'
import { RejectWorkItemDialog } from './WorkItemActionDialogs'
import { useApproveWorkItem, useRejectWorkItem } from '../../hooks/useAgentWorkItems'
import { DS_BG, DS_BORDER } from '../../lib/ds'
type OpenDialog = 'none' | 'approve' | 'decide' | 'reject' | 'edit' | 'answer'
interface Props {
item: AgentWorkItem
/** Springt zu den Fundstellen — «Quelle öffnen» ohne echtes Zielsystem. */
onOpenSource: () => void
/** Springt im Drawer zum Abschnitt «Quellen» — öffnet keine neue Seite. */
onShowSources: () => void
/** Öffnet das zugehörige Objekt in «Meine Objekte». */
onOpenProperty: () => void
}
/**
* Aktionsleiste eines Vorgangs (§5.6).
* Aktionsleiste eines Vorgangs.
*
* Freigabe und Entscheidung laufen bewusst über einen Bestätigungsdialog: sie
* sind irreversibel und dürfen nicht versehentlich ausgelöst werden (§17.3).
* Die 1-Klick-Bestätigung ist die eine Ausnahme — sie ist als schneller Weg
* fachlich so vorgesehen und im Katalog explizit für Reto vorgesehen.
* Pendent: Freigeben, Zurückweisen, Quellen anzeigen, Inserat anzeigen.
* Erledigt: nur noch der Weg zum Objekt — entschieden ist entschieden.
*
* Die Freigabe läuft über einen Bestätigungsdialog: sie ist irreversibel und
* darf nicht versehentlich ausgelöst werden.
*/
export function WorkItemActions({ item, onOpenSource }: Props) {
const [dialog, setDialog] = useState<OpenDialog>('none')
export function WorkItemActions({ item, onShowSources, onOpenProperty }: Props) {
const [confirmApprove, setConfirmApprove] = useState(false)
const [confirmReject, setConfirmReject] = useState(false)
const approve = useApproveWorkItem()
const reject = useRejectWorkItem()
const saveEdit = useSaveWorkItemEdit()
const answer = useAnswerWorkItemQuery()
const markDone = useMarkWorkItemDone()
const busy = approve.isPending || reject.isPending
const busy =
approve.isPending || reject.isPending || saveEdit.isPending || answer.isPending || markDone.isPending
const can = (action: string) => item.availableActions.includes(action as never)
const close = () => setDialog('none')
const runApprove = () => {
approve.mutate(item.id, { onSuccess: close })
}
const hasSources = item.sourceReferences.length > 0
const hasProperty = !!item.objectId
return (
<>
@@ -62,158 +48,88 @@ export function WorkItemActions({ item, onOpenSource }: Props) {
flexShrink: 0,
}}
>
{can(AgentWorkItemAction.ONE_CLICK_CONFIRM) && (
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<CheckCheck size={15} />}
onClick={runApprove}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Mit einem Klick bestätigen
</Button>
)}
{item.requiresDecision ? (
<>
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<Check size={15} />}
onClick={() => setConfirmApprove(true)}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Freigeben
</Button>
{can(AgentWorkItemAction.APPROVE) && (
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<Check size={15} />}
onClick={() => setDialog('approve')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Freigeben
</Button>
)}
<Button
variant="outlined"
color="error"
size="small"
disabled={busy}
startIcon={<X size={15} />}
onClick={() => setConfirmReject(true)}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Zurückweisen
</Button>
{can(AgentWorkItemAction.DECIDE) && (
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<Scale size={15} />}
onClick={() => setDialog('decide')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Entscheidung treffen
</Button>
)}
{hasSources && (
<Button
size="small"
startIcon={<FileSearch size={15} />}
onClick={onShowSources}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Quellen anzeigen
</Button>
)}
{can(AgentWorkItemAction.ANSWER_QUERY) && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<MessageSquareReply size={15} />}
onClick={() => setDialog('answer')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Rückfrage beantworten
</Button>
)}
{can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<Pencil size={15} />}
onClick={() => setDialog('edit')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Anpassen
</Button>
)}
{can(AgentWorkItemAction.REJECT) && (
<Button
variant="outlined"
color="error"
size="small"
disabled={busy}
startIcon={<X size={15} />}
onClick={() => setDialog('reject')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Zurückweisen
</Button>
)}
{can(AgentWorkItemAction.MARK_DONE) && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<Check size={15} />}
onClick={() => markDone.mutate(item.id)}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Als erledigt markieren
</Button>
)}
{can(AgentWorkItemAction.OPEN_SOURCE) && item.sourceReferences.length > 0 && (
<Button
size="small"
startIcon={<FileSearch size={15} />}
onClick={onOpenSource}
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
>
Quelle öffnen
</Button>
{hasProperty && (
<Button
size="small"
startIcon={<Building2 size={15} />}
onClick={onOpenProperty}
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
>
Inserat anzeigen
</Button>
)}
</>
) : (
hasProperty && (
<Button
variant="outlined"
size="small"
startIcon={<Building2 size={15} />}
onClick={onOpenProperty}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Objekt anzeigen
</Button>
)
)}
</Box>
<ConfirmDialog
open={dialog === 'approve'}
open={confirmApprove}
title="Vorgang freigeben"
message={`«${item.title}» wird freigegeben und wechselt zu den erledigten Aufträgen. Die Freigabe wird protokolliert und lässt sich nicht rückgängig machen.`}
confirmLabel="Freigeben"
onConfirm={runApprove}
onCancel={close}
/>
<ConfirmDialog
open={dialog === 'decide'}
title="Entscheidung bestätigen"
message={
item.decisionQuestion
? `${item.decisionQuestion} Mit der Bestätigung wird der vorgeschlagene Weg freigegeben und protokolliert.`
: `«${item.title}» wird entschieden und protokolliert.`
}
confirmLabel="Bestätigen"
onConfirm={runApprove}
onCancel={close}
onConfirm={() => {
approve.mutate(item.id, { onSuccess: () => setConfirmApprove(false) })
}}
onCancel={() => setConfirmApprove(false)}
/>
<RejectWorkItemDialog
open={dialog === 'reject'}
open={confirmReject}
title={item.title}
busy={reject.isPending}
onCancel={close}
onConfirm={(reason) => reject.mutate({ id: item.id, reason }, { onSuccess: close })}
/>
<EditWorkItemDialog
open={dialog === 'edit'}
title={item.title}
fields={item.editableFields ?? []}
busy={saveEdit.isPending}
onCancel={close}
onConfirm={(fields: AgentEditableField[]) =>
saveEdit.mutate({ id: item.id, fields }, { onSuccess: close })
onCancel={() => setConfirmReject(false)}
onConfirm={(reason) =>
reject.mutate({ id: item.id, reason }, { onSuccess: () => setConfirmReject(false) })
}
/>
<AnswerQueryDialog
open={dialog === 'answer'}
question={item.decisionQuestion ?? item.title}
busy={answer.isPending}
onCancel={close}
onConfirm={(text) => answer.mutate({ id: item.id, answer: text }, { onSuccess: close })}
/>
</>
)
}
+49 -78
View File
@@ -1,16 +1,10 @@
import { memo } from 'react'
import { Box, Typography } from '@mui/material'
import { AlertTriangle, Building2, Radio } from 'lucide-react'
import { memo, useMemo } from 'react'
import { Box, Tooltip, Typography } from '@mui/material'
import type { AgentWorkItem } from '../../domain/agentWorkItem'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar'
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
import {
AGENT_CHANNEL_LABELS,
AGENT_WORK_ITEM_KIND_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime, formatTeamRelative } from '../../lib/teamClock'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
interface Props {
item: AgentWorkItem
@@ -19,18 +13,30 @@ interface Props {
onSelect: (id: string) => void
}
const AVATAR_PX = 44
/**
* Ein Eintrag im Bearbeitungsverlauf.
*
* `React.memo` ist hier nicht optional: die Liste rendert bis zu 14 Karten und
* der Drawer verändert bei jeder Auswahl den Zustand der Seite — ohne Memo
* würde jede Auswahl die gesamte Liste neu zeichnen (CLAUDE.md §10.2).
* Bewusst auf das Nötigste reduziert: wer, was, wann. Priorität, Status,
* Vorgangstyp und Kanal standen auf praktisch jeder Karte und trugen deshalb
* keine Information mehr — sie sind in den Filtern und im Detail weiterhin
* erreichbar. Übrig bleibt, was die Karte beantworten soll: welches Problem
* liegt bei welcher Liegenschaft, und wer hat daran gearbeitet.
*
* `React.memo` ist nicht optional: der Drawer verändert bei jeder Auswahl den
* Zustand der Seite, ohne Memo würde die ganze Liste neu zeichnen (CLAUDE.md §10.2).
*/
export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, onSelect }: Props) {
const kindLabel = AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind
const channelLabel = AGENT_CHANNEL_LABELS[item.sourceChannel] ?? item.sourceChannel
const timestamp = item.completedAt ?? item.createdAt
// «Kurze Problemstellung Name der Immobilie». Fehlt der Objektbezug, bleibt
// der Titel allein stehen statt mit einem leeren Gedankenstrich zu enden.
const title = useMemo(
() => (item.objectLabel ? `${item.title} ${item.objectLabel}` : item.title),
[item.title, item.objectLabel],
)
return (
<Box
component="article"
@@ -48,20 +54,28 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
border: `1px solid ${selected ? DS_TEXT.brand : DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 1.75,
p: 2,
cursor: 'pointer',
boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card,
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 },
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
{agent && <AgentAvatar agent={agent} size="medium" />}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
{agent && (
<Tooltip arrow title={`${agent.name} · ${agent.role}`}>
{/* 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 }}>
{/* 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 }}>
{agent?.name ?? 'Unbekannt'}
</Typography>
@@ -73,67 +87,24 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
</Typography>
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.5 }}>
{item.title}
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.75 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
<Typography
title={item.summary}
variant="body2"
sx={{
color: DS_TEXT.secondary,
mt: 0.5,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{item.summary}
</Typography>
{/* Grund der Rückfrage — der wichtigste Satz bei pendenten Vorgängen */}
{item.requiresDecision && item.escalationReason && (
<Box
sx={{
display: 'flex',
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}
</Typography>
</Box>
)}
{/* Fusszeile: Vorgangstyp, Objektbezug, Quellkanal, Status */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1.25 }}>
<AgentPriorityBadge priority={item.priority} />
<AgentWorkItemStatusBadge status={item.status} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{kindLabel}
</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>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
<Radio size={12} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{channelLabel}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto', whiteSpace: 'nowrap' }}>
{formatTeamRelative(timestamp)}
</Typography>
</Box>
</Box>
</Box>
</Box>
+58 -85
View File
@@ -1,30 +1,29 @@
import { useCallback, useRef } from 'react'
import { Box, Divider, Drawer, IconButton, Typography } from '@mui/material'
import { HelpCircle, X } from 'lucide-react'
import { Box, Button, Drawer, IconButton, Typography } from '@mui/material'
import { ArrowUpRight, HelpCircle, X } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useTeamStore } from '../../stores/teamStore'
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import { AgentAvatar } from './AgentAvatar'
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
import { WorkItemActions } from './WorkItemActions'
import {
DetailFieldList,
DetailSectionTitle,
MessageThread,
ProcessingStepList,
SourceReferenceList,
} from './WorkItemDetailSections'
import { DetailFieldList, DetailSectionTitle, MessageThread, SourceReferenceList } from './WorkItemDetailSections'
import { PanelLoadingState } from '../ui'
import { AGENT_WORK_ITEM_KIND_LABELS } from '../../lib/constants'
import { ROUTES } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
/** Das Anfragencenter führt den vollständigen Schriftverkehr. */
const ANFRAGENCENTER_ROUTE = '/supply/anfragen'
/**
* Detailansicht eines Vorgangs (§5.8).
* Detailansicht eines Vorgangs.
*
* Als Drawer statt eigener Route: der Nutzer arbeitet eine Liste ab und will
* nach jeder Entscheidung sofort wieder in ihr stehen — ein Seitenwechsel
* würde bei jedem Vorgang den Kontext zerstören.
* Bewusst schmal gehalten: Priorität, Status, Vorgangstyp, Verarbeitungsschritte
* und Aktionshistorie sind entfallen. Sie beschrieben die Maschine, nicht den
* Fall. Übrig bleibt, was für die Entscheidung zählt — worum es geht, was der
* Mitarbeitende bisher hat, welche Nachricht es ausgelöst hat und woher die
* Angaben stammen.
*/
export function WorkItemDetailDrawer() {
const selectedId = useTeamStore(s => s.selectedWorkItemId)
@@ -32,6 +31,7 @@ export function WorkItemDetailDrawer() {
const { data: item, isLoading } = useAgentWorkItem(selectedId)
const { data: agents = [] } = useTeamAgents()
const sourcesRef = useRef<HTMLDivElement | null>(null)
const navigate = useNavigate()
const agent = agents.find(a => a.id === item?.agentId)
const close = useCallback(() => setSelectedId(null), [setSelectedId])
@@ -40,14 +40,30 @@ export function WorkItemDetailDrawer() {
sourcesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, [])
// Vor die Callbacks gezogen: sonst steht `item` im Rumpf, aber nur
// `item?.objectId` in den Abhängigkeiten — die Memoisierung wäre unhaltbar.
const objectId = item?.objectId
// Der Objektbezug führt in «Meine Objekte» — bestehende Route, bestehende ID.
const openProperty = useCallback(() => {
if (!objectId) return
navigate(ROUTES.SUPPLY.PROPERTIES, { state: { propertyId: objectId } })
}, [navigate, objectId])
// Der ganze Verlauf gehört ins Anfragencenter, nicht in den Drawer.
const openCorrespondence = useCallback(() => {
navigate(ANFRAGENCENTER_ROUTE, { state: { objectId } })
}, [navigate, objectId])
const firstMessage = item?.messageThread?.slice(0, 1) ?? []
return (
<Drawer
anchor="right"
open={!!selectedId}
onClose={close}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560, lg: 640 }, display: 'flex', flexDirection: 'column' } } }}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 520, lg: 600 }, display: 'flex', flexDirection: 'column' } } }}
>
{/* Kopfbereich */}
<Box
sx={{
display: 'flex',
@@ -58,13 +74,13 @@ export function WorkItemDetailDrawer() {
flexShrink: 0,
}}
>
{agent && <AgentAvatar agent={agent} size="medium" />}
{agent && <AgentAvatar agent={agent} size="medium" tone={agent.avatarTone} />}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
</Typography>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, lineHeight: 1.35 }}>
{item?.title ?? 'Vorgang wird geladen'}
{item ? (item.objectLabel ? `${item.title} ${item.objectLabel}` : item.title) : 'Vorgang wird geladen'}
</Typography>
</Box>
<IconButton size="small" onClick={close} aria-label="Detailansicht schliessen">
@@ -72,32 +88,17 @@ export function WorkItemDetailDrawer() {
</IconButton>
</Box>
{/* Inhalt */}
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
{isLoading && <PanelLoadingState />}
{item && (
<Box sx={{ p: 2, display: 'grid', gap: 2.5 }}>
{/* Metazeile */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<AgentPriorityBadge priority={item.priority} />
<AgentWorkItemStatusBadge status={item.status} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind}
</Typography>
{item.objectId && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
· {item.objectId}{item.objectLabel ? ` · ${item.objectLabel}` : ''}
</Typography>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto' }}>
{formatTeamDateTime(item.completedAt ?? item.createdAt)}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{formatTeamDateTime(item.completedAt ?? item.createdAt)}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.summary}</Typography>
{/* Rückfrage — bei pendenten Vorgängen die wichtigste Information */}
{item.requiresDecision && (item.escalationReason || item.decisionQuestion) && (
<Box
sx={{
@@ -110,7 +111,7 @@ export function WorkItemDetailDrawer() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<HelpCircle size={15} color={DS_TEXT.warning} aria-hidden />
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', color: DS_TEXT.warningDark }}>
Warum ist ein Mensch erforderlich?
Hierzu brauche ich Ihre Entscheidung
</Typography>
</Box>
{item.escalationReason && (
@@ -133,30 +134,25 @@ export function WorkItemDetailDrawer() {
</Box>
)}
{item.messageThread && item.messageThread.length > 0 && (
{/* Nur die auslösende Nachricht — der Rest liegt im Anfragencenter. */}
{firstMessage.length > 0 && (
<Box>
<DetailSectionTitle>Nachrichtenverlauf</DetailSectionTitle>
<MessageThread messages={item.messageThread} />
</Box>
)}
{item.inputs.length > 0 && (
<Box>
<DetailSectionTitle>Eingabedaten</DetailSectionTitle>
<DetailFieldList fields={item.inputs} />
</Box>
)}
{item.processingSteps.length > 0 && (
<Box>
<DetailSectionTitle>Verarbeitungsschritte</DetailSectionTitle>
<ProcessingStepList steps={item.processingSteps} />
<DetailSectionTitle>Auslösende Nachricht</DetailSectionTitle>
<MessageThread messages={firstMessage} />
<Button
size="small"
endIcon={<ArrowUpRight size={14} />}
onClick={openCorrespondence}
sx={{ textTransform: 'none', fontWeight: 600, mt: 0.5, px: 0 }}
>
Gesamte Korrespondenz im Anfragencenter anzeigen
</Button>
</Box>
)}
{item.sourceReferences.length > 0 && (
<Box ref={sourcesRef} sx={{ scrollMarginTop: 8 }}>
<DetailSectionTitle>Fundstellen</DetailSectionTitle>
<DetailSectionTitle>Quellen</DetailSectionTitle>
<SourceReferenceList references={item.sourceReferences} />
</Box>
)}
@@ -167,39 +163,16 @@ export function WorkItemDetailDrawer() {
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
</Box>
)}
{item.history.length > 0 && (
<Box>
<DetailSectionTitle>Aktionshistorie</DetailSectionTitle>
<Box sx={{ display: 'grid', gap: 0.75 }}>
{item.history.map((entry) => (
<Box key={entry.id} sx={{ display: 'flex', gap: 1, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, minWidth: 128 }}>
{formatTeamDateTime(entry.at)}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{entry.action}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{entry.actor}
</Typography>
{entry.note && (
<>
<Divider flexItem orientation="vertical" />
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{entry.note}</Typography>
</>
)}
</Box>
))}
</Box>
</Box>
)}
</Box>
)}
</Box>
{item && item.availableActions.length > 0 && (
<WorkItemActions item={item} onOpenSource={scrollToSources} />
{item && (
<WorkItemActions
item={item}
onShowSources={scrollToSources}
onOpenProperty={openProperty}
/>
)}
</Drawer>
)
+7 -48
View File
@@ -1,17 +1,13 @@
import { Box, Button, InputAdornment, MenuItem, TextField } from '@mui/material'
import { RotateCcw, Search } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentChannelType } from '../../domain/teamAgent'
import { AgentDomainArea, AgentWorkItemKind, AgentWorkItemPriority, AgentWorkItemStatus } from '../../domain/agentWorkItem'
import { AgentDomainArea, AgentWorkItemStatus } from '../../domain/agentWorkItem'
import { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters'
import { useTeamStore } from '../../stores/teamStore'
import {
AGENT_PERIOD_LABELS,
AGENT_DOMAIN_AREA_LABELS,
AGENT_WORK_ITEM_KIND_LABELS,
AGENT_PRIORITY_LABELS,
AGENT_WORK_ITEM_STATUS_LABELS,
AGENT_CHANNEL_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
@@ -76,9 +72,6 @@ export function WorkItemFilterBar({ agents }: Props) {
const period = useTeamStore(s => s.historyPeriod)
const agentId = useTeamStore(s => s.historyAgentId)
const area = useTeamStore(s => s.historyArea)
const kind = useTeamStore(s => s.historyKind)
const priority = useTeamStore(s => s.historyPriority)
const channel = useTeamStore(s => s.historyChannel)
const status = useTeamStore(s => s.historyStatus)
const search = useTeamStore(s => s.historySearch)
const sort = useTeamStore(s => s.historySort)
@@ -86,9 +79,6 @@ export function WorkItemFilterBar({ agents }: Props) {
const setPeriod = useTeamStore(s => s.setHistoryPeriod)
const setAgentId = useTeamStore(s => s.setHistoryAgentId)
const setArea = useTeamStore(s => s.setHistoryArea)
const setKind = useTeamStore(s => s.setHistoryKind)
const setPriority = useTeamStore(s => s.setHistoryPriority)
const setChannel = useTeamStore(s => s.setHistoryChannel)
const setStatus = useTeamStore(s => s.setHistoryStatus)
const setSearch = useTeamStore(s => s.setHistorySearch)
const setSort = useTeamStore(s => s.setHistorySort)
@@ -98,9 +88,6 @@ export function WorkItemFilterBar({ agents }: Props) {
period !== AgentPeriod.ALL ||
agentId !== 'ALL' ||
area !== 'ALL' ||
kind !== 'ALL' ||
priority !== 'ALL' ||
channel !== 'ALL' ||
status !== 'ALL' ||
search.trim() !== '' ||
sort !== AgentWorkItemSort.NEWEST
@@ -110,22 +97,21 @@ export function WorkItemFilterBar({ agents }: Props) {
return (
<Box
sx={{
display: 'flex',
display: 'grid',
gap: 1.25,
flexWrap: 'wrap',
alignItems: 'center',
px: 3,
py: 1.5,
borderBottom: `1px solid ${DS_BORDER.default}`,
bgcolor: DS_BG.page,
}}
>
{/* Zeile 1: nur die Suche — sie ist der häufigste Einstieg. */}
<TextField
size="small"
placeholder="Vorgänge durchsuchen"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
sx={{ maxWidth: 420, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
slotProps={{
input: {
startAdornment: (
@@ -138,6 +124,8 @@ export function WorkItemFilterBar({ agents }: Props) {
}}
/>
{/* Zeile 2: die verbleibenden Filter, auf dem Desktop in einer Zeile. */}
<Box sx={{ display: 'flex', gap: 1.25, flexWrap: 'wrap', alignItems: 'center' }}>
<FilterSelect
label="Zeitraum"
value={period === AgentPeriod.ALL ? 'ALL' : period}
@@ -167,36 +155,6 @@ export function WorkItemFilterBar({ agents }: Props) {
onChange={setArea}
/>
<FilterSelect
label="Vorgangstyp"
value={kind}
options={Object.values(AgentWorkItemKind)}
labels={AGENT_WORK_ITEM_KIND_LABELS}
allLabel="Alle Vorgangstypen"
onChange={setKind}
width={186}
/>
<FilterSelect
label="Priorität"
value={priority}
options={Object.values(AgentWorkItemPriority)}
labels={AGENT_PRIORITY_LABELS}
allLabel="Alle Prioritäten"
onChange={setPriority}
width={150}
/>
<FilterSelect
label="Kanal"
value={channel}
options={Object.values(AgentChannelType)}
labels={AGENT_CHANNEL_LABELS}
allLabel="Alle Kanäle"
onChange={setChannel}
width={175}
/>
<FilterSelect
label="Status"
value={status}
@@ -230,6 +188,7 @@ export function WorkItemFilterBar({ agents }: Props) {
Filter zurücksetzen
</Button>
)}
</Box>
</Box>
)
}
+8 -2
View File
@@ -14,6 +14,12 @@ interface Props {
emptyDescription: string
}
/**
* Abstand zwischen zwei Vorgängen. Bewusst grosszügig: die Karten tragen kaum
* noch Farbe, die Trennung übernimmt der Weissraum.
*/
const LIST_GAP = 2.5
export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
const { data: items = [], isLoading, isError, refetch } = useAgentWorkItems(filters)
const { data: agents = [] } = useTeamAgents()
@@ -25,7 +31,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
if (isLoading) {
return (
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
<Box sx={{ p: 3, display: 'grid', gap: LIST_GAP }}>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
@@ -42,7 +48,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
}
return (
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
<Box sx={{ p: 3, display: 'grid', gap: LIST_GAP }}>
{items.map((item) => (
<WorkItemCard
key={item.id}
@@ -1,164 +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 den Avatar mit den Initialen des Mitarbeiters', () => {
renderWithProviders(<AgentCard agent={ferdi} 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()} />)
// `alt` allein genügt bei MUI nicht: ohne `src` rendert Avatar kein <img>,
// und der Wert landet nirgends im DOM. Der Name muss deshalb über
// role="img" + aria-label direkt am Element hängen (§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)
})
})
@@ -63,25 +63,27 @@ function renderCard(
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('WorkItemCard — Inhalt', () => {
it('rendert Titel, Zusammenfassung, Mitarbeitername und Rollenbezeichnung', () => {
it('rendert Titel mit Liegenschaft, Zusammenfassung, Mitarbeitername und Rollenbezeichnung', () => {
const item = workItem(COMPLETED_ID)
const agent = agentOf(item)
renderCard(item)
const card = screen.getByRole('button')
expect(within(card).getByText(item.title)).toBeInTheDocument()
// Titelformat: «Kurze Problemstellung Name der Immobilie»
const expectedTitle = item.objectLabel ? `${item.title} ${item.objectLabel}` : item.title
expect(within(card).getByText(expectedTitle)).toBeInTheDocument()
expect(within(card).getByText(item.summary)).toBeInTheDocument()
expect(within(card).getByText(agent.name)).toBeInTheDocument()
expect(within(card).getByText(agent.role)).toBeInTheDocument()
})
it('zeigt bei einem Vorgang mit Objektbezug die Objekt-ID', () => {
it('zeigt die Objekt-ID nicht mehr — die Liegenschaft steht im Titel', () => {
const item = workItem(COMPLETED_ID)
const objectId = required(item.objectId, `Vorgang «${item.id}» hat keine Objekt-ID`)
renderCard(item)
const card = screen.getByRole('button')
expect(within(card).getByText((content) => content.includes(objectId))).toBeInTheDocument()
expect(within(card).queryByText((content) => content.includes(objectId))).toBeNull()
})
it('zeigt ohne Objektbezug keine Objekt-ID', () => {
@@ -95,14 +97,14 @@ describe('WorkItemCard — Inhalt', () => {
})
describe('WorkItemCard — Grund der Rückfrage', () => {
it('zeigt bei einem pendenten Vorgang den escalationReason', () => {
it('zeigt den Rückfragegrund nicht mehr auf der Karte — er steht im Detail', () => {
const item = workItem(PENDING_ID)
const reason = required(item.escalationReason, `Vorgang «${item.id}» hat keinen Rückfragegrund`)
expect(item.requiresDecision).toBe(true)
renderCard(item)
const card = screen.getByRole('button')
expect(within(card).getByText(reason)).toBeInTheDocument()
expect(within(card).queryByText(reason)).toBeNull()
})
it('zeigt bei einem erledigten Vorgang keinen escalationReason', () => {
+93
View File
@@ -0,0 +1,93 @@
/**
* Property On — Porträts aller digitalen Mitarbeitenden.
*
* Die Bilder stammen aus dem Konzeptdokument, wo sie als eingebettete JPEGs am
* jeweiligen Agenten hängen. Sie sind fachliche Vorgabe, nicht Dekoration: das
* digitale Personalbüro zeigt Mitarbeitende mit Gesicht, keine abstrakten
* Werkzeuge.
*
* Die Zuordnung liegt bewusst in der Darstellungsschicht und nicht im Domain-Typ
* oder in den Mockdaten. Ein Domain-Modell, das Binärdateien importiert, wäre an
* den Bundler gekoppelt und weder im Test noch später gegen ein echtes Backend
* sauber verwendbar.
*
* Fehlt zu einer ID ein Bild, fällt `AgentAvatar` auf die Initialen zurück.
*
* ACHTUNG: generiert. Nicht von Hand pflegen.
*/
import bruno from '../../assets/team/bruno.jpg'
import ferdi from '../../assets/team/ferdi.jpg'
import lea from '../../assets/team/lea.jpg'
import livia from '../../assets/team/livia.jpg'
import nora from '../../assets/team/nora.jpg'
import reto from '../../assets/team/reto.jpg'
import sina from '../../assets/team/sina.jpg'
import ada from '../../assets/team/ada.jpg'
import carla from '../../assets/team/carla.jpg'
import diego from '../../assets/team/diego.jpg'
import dora from '../../assets/team/dora.jpg'
import emil from '../../assets/team/emil.jpg'
import filip from '../../assets/team/filip.jpg'
import kira from '../../assets/team/kira.jpg'
import lars from '../../assets/team/lars.jpg'
import mia from '../../assets/team/mia.jpg'
import paul from '../../assets/team/paul.jpg'
import pia from '../../assets/team/pia.jpg'
import rea from '../../assets/team/rea.jpg'
import rico from '../../assets/team/rico.jpg'
import uli from '../../assets/team/uli.jpg'
import vela from '../../assets/team/vela.jpg'
import vera from '../../assets/team/vera.jpg'
import vinz from '../../assets/team/vinz.jpg'
import vito from '../../assets/team/vito.jpg'
import beat from '../../assets/team/beat.jpg'
import fabio from '../../assets/team/fabio.jpg'
import nadia from '../../assets/team/nadia.jpg'
import oskar from '../../assets/team/oskar.jpg'
import otto from '../../assets/team/otto.jpg'
import sven from '../../assets/team/sven.jpg'
import tino from '../../assets/team/tino.jpg'
import gian from '../../assets/team/gian.jpg'
import ida from '../../assets/team/ida.jpg'
import jana from '../../assets/team/jana.jpg'
import zeno from '../../assets/team/zeno.jpg'
export const AGENT_PHOTOS: Record<string, string | undefined> = {
bruno,
ferdi,
lea,
livia,
nora,
reto,
sina,
ada,
carla,
diego,
dora,
emil,
filip,
kira,
lars,
mia,
paul,
pia,
rea,
rico,
uli,
vela,
vera,
vinz,
vito,
beat,
fabio,
nadia,
oskar,
otto,
sven,
tino,
gian,
ida,
jana,
zeno,
}
+103
View File
@@ -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
+30 -1
View File
@@ -2,6 +2,17 @@
export { AgentAvatar } from './AgentAvatar'
export type { AgentAvatarSize } from './AgentAvatar'
export { AgentAvatarGroup } from './AgentAvatarGroup'
export { AGENT_PHOTOS } from './agentPhotos'
export {
RING_SPECS,
RING_REFERENCE_PX,
ringSpecFor,
layoutArc,
WHEEL_ARC,
WHEEL_ACTIVE_ANGLE_DEG,
} from './agentRingConfig'
export type { RingSpec, RingNodePosition, ArcOptions } from './agentRingConfig'
export {
AgentStatusBadge,
@@ -16,14 +27,32 @@ export {
export { TeamPageHeader } from './TeamPageHeader'
export { TeamSectionHeader } from './TeamSectionHeader'
export { TeamKpiSection } from './TeamKpiSection'
export { AgentCard } from './AgentCard'
// Kreisdarstellung der Belegschaft — ersetzt das frühere Kernteam-Kartenraster
export { TeamLevelTabs } from './TeamLevelTabs'
export { TeamOverviewRing } from './TeamOverviewRing'
export { AgentRing } from './AgentRing'
export { AgentRingNode } from './AgentRingNode'
export { AgentPreviewPopover } from './AgentPreviewPopover'
export { ConnectionSummaryList } from './ConnectionSummaryList'
// Personalverwaltung
export {
AgentDossier,
DOSSIER_TABS,
DEFAULT_DOSSIER_SEGMENT,
isDossierSegment,
resolveDossierSegment,
} from './AgentDossier'
export type { DossierSegment } from './AgentDossier'
export { AgentDescriptionTab } from './AgentDescriptionTab'
export { RotatableAgentWheel } from './RotatableAgentWheel'
export { AgentListPanel } from './AgentListPanel'
export { AgentDossierHeader } from './AgentDossierHeader'
export { AgentInfoBox } from './AgentInfoBox'
export { AgentTasksTab } from './AgentTasksTab'
export { AgentMetricsTab } from './AgentMetricsTab'
export { AgentConnectionsTab } from './AgentConnectionsTab'
export { AgentChannelsTab } from './AgentChannelsTab'
export { AgentSystemsTab } from './AgentSystemsTab'
export { AgentSettingsTab } from './AgentSettingsTab'
+66
View File
@@ -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
}
+34
View File
@@ -330,6 +330,40 @@ export const AGENT_PERIOD_LABELS: Record<string, string> = {
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: 300800 ms). */
export const AGENT_SIM_DELAY_MS = 420
export const AGENT_SIM_DELAY_SLOW_MS = 760
+385
View File
@@ -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)
-1
View File
@@ -72,7 +72,6 @@ export default function Bearbeitungsverlauf() {
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
title="Bearbeitungsverlauf"
description="Was die digitalen Mitarbeiter selbständig erledigt haben — und wo sie eine Entscheidung der Bewirtschaftung brauchen."
tabs={
<Tabs
value={activeTab}
+76 -36
View File
@@ -1,19 +1,53 @@
import { useCallback, useState } from 'react'
import { Box, Typography } from '@mui/material'
import { AgentConnectionCategory } from '../../domain/agentConnection'
import { DS_TEXT } from '../../lib/ds'
/** Vier Karten pro Zeile auf grossen Schirmen, zwei auf Tablet, eine auf Mobil. */
const GRID_COLUMNS = {
xs: '1fr',
md: 'repeat(2, minmax(0, 1fr))',
xl: 'repeat(4, minmax(0, 1fr))',
} as const
/**
* Kanäle zuerst, Systeme danach. Kanäle sind das, worüber Aufträge hereinkommen
* und Ergebnisse hinausgehen; Systeme das, woraus gelesen und wohin geschrieben
* wird. Die Reihenfolge folgt dem Arbeitsablauf.
*/
const SECTIONS = [
{
title: 'Kanäle',
categories: [
AgentConnectionCategory.EMAIL_M365,
AgentConnectionCategory.WHATSAPP,
AgentConnectionCategory.TEAMS,
AgentConnectionCategory.CALENDAR,
AgentConnectionCategory.PHONE_VOICE,
] as string[],
},
{
title: 'Systeme',
categories: [
AgentConnectionCategory.DOCUMENT_STORE,
AgentConnectionCategory.ERP,
AgentConnectionCategory.CRM,
AgentConnectionCategory.PUBLIC_SOURCES,
] as string[],
},
]
import {
TeamPageHeader,
ConnectionCard,
ConnectionWizard,
} from '../../components/team'
import { CardSkeleton, ConfirmDialog, ErrorState } from '../../components/ui'
import { AgentConnectionStatus } from '../../domain/teamAgent'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import {
useAgentConnections,
useDisconnectAgentConnection,
useTestAgentConnection,
} from '../../hooks/useAgentConnections'
import { DS_TEXT } from '../../lib/ds'
/**
* Subreiter «Kanäle & Systeme» (§13).
@@ -42,46 +76,52 @@ export default function KanaeleSysteme() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
title="Kanäle & Systeme"
description="Alle zentralen Verbindungen der Organisation. Die digitalen Mitarbeiter arbeiten in den Werkzeugen, die Sie ohnehin einsetzen."
/>
<TeamPageHeader title="Kanäle & Systeme" />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{isError ? (
<ErrorState message="Die Verbindungen konnten nicht geladen werden." onRetry={() => refetch()} />
) : (
<>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}>
{connections.filter(c => c.status === AgentConnectionStatus.CONNECTED).length} von {connections.length}{' '}
Verbindungen sind eingerichtet.
</Typography>
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: {
xs: '1fr',
md: 'repeat(2, 1fr)',
xl: 'repeat(3, 1fr)',
},
}}
>
{isLoading
? [0, 1, 2, 3, 4, 5].map((i) => <CardSkeleton key={i} />)
: connections.map((connection) => (
<ConnectionCard
key={connection.id}
connection={connection}
agents={agents}
busy={busy}
onConfigure={handleConfigure}
onDisconnect={handleDisconnect}
onTest={handleTest}
/>
))}
</Box>
{isLoading ? (
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: GRID_COLUMNS }}>
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => <CardSkeleton key={i} />)}
</Box>
) : (
SECTIONS.map((section) => {
const items = connections.filter(c => section.categories.includes(c.category))
if (items.length === 0) return null
return (
<Box component="section" key={section.title} sx={{ mb: 4 }}>
<Typography
component="h2"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
mb: 1.5,
}}
>
{section.title}
</Typography>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: GRID_COLUMNS }}>
{items.map((connection) => (
<ConnectionCard
key={connection.id}
connection={connection}
busy={busy}
onConfigure={handleConfigure}
onDisconnect={handleDisconnect}
onTest={handleTest}
/>
))}
</Box>
</Box>
)
})
)}
</>
)}
</Box>
+96 -82
View File
@@ -1,132 +1,146 @@
import { useCallback, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router'
import { Box, Tab, Tabs, useMediaQuery, useTheme } from '@mui/material'
import { useCallback, useEffect, useMemo } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import { Box, ToggleButton, ToggleButtonGroup, useMediaQuery, useTheme } from '@mui/material'
import { CircleDot, Rows3 } from 'lucide-react'
import {
TeamPageHeader,
AgentListPanel,
AgentDossierHeader,
AgentInfoBox,
AgentTasksTab,
AgentChannelsTab,
AgentSystemsTab,
AgentSettingsTab,
AgentProtocolTab,
AgentDossier,
RotatableAgentWheel,
resolveDossierSegment,
} from '../../components/team'
import type { DossierSegment } from '../../components/team'
import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui'
import { agentDirectory } from '../../mock-data/agentDirectory'
import { useTeamAgents } from '../../hooks/useTeamAgents'
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 DOSSIER_TABS = [
{ segment: 'aufgaben', label: 'Aufgaben' },
{ segment: 'kanaele', label: 'Kanäle' },
{ segment: 'systeme', label: 'Systeme' },
{ segment: 'einstellungen', label: 'Einstellungen' },
{ segment: 'protokoll', label: 'Protokoll' },
] as const
const VIEW_PARAM = 'view'
const VIEW = { DOSSIER: 'dossier', ORG: 'organigramm' } as const
type PersonnelView = typeof VIEW[keyof typeof VIEW]
type DossierSegment = typeof DOSSIER_TABS[number]['segment']
const DEFAULT_SEGMENT: DossierSegment = 'aufgaben'
function isSegment(value: string | undefined): value is DossierSegment {
return DOSSIER_TABS.some(t => t.segment === value)
function isView(value: string | null): value is PersonnelView {
return value === VIEW.DOSSIER || value === VIEW.ORG
}
/** Nur die sieben Kernteammitglieder — sie besitzen ein Personaldossier. */
const coreEntries = agentDirectory.filter(a => a.isCore)
export default function Personalverwaltung() {
const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const theme = useTheme()
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
const activeSegment: DossierSegment = isSegment(tab) ? tab : DEFAULT_SEGMENT
const selectedAgent = agents.find(a => a.id === agentId) ?? null
const view: PersonnelView = isView(searchParams.get(VIEW_PARAM))
? (searchParams.get(VIEW_PARAM) as PersonnelView)
: VIEW.DOSSIER
const activeSegment: DossierSegment = resolveDossierSegment(tab)
const selectedAgent = useMemo(() => agents.find(a => a.id === agentId) ?? null, [agents, agentId])
// Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres
// Dossier wäre für den Nutzer eine Sackgasse.
useEffect(() => {
if (!agentId && agents.length > 0) {
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}`, { replace: true })
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}?${VIEW_PARAM}=${view}`, { replace: true })
}
}, [agentId, agents, navigate])
}, [agentId, agents, navigate, view])
const selectAgent = useCallback(
(id: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${id}/${activeSegment}`),
[navigate, activeSegment],
const goTo = useCallback(
(nextAgentId: string, segment: DossierSegment) => {
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${nextAgentId}/${segment}?${VIEW_PARAM}=${view}`)
},
[navigate, view],
)
const selectTab = useCallback(
const selectAgent = useCallback(
(id: string) => goTo(id, activeSegment),
[goTo, activeSegment],
)
const selectSegment = useCallback(
(segment: DossierSegment) => {
if (agentId) navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}/${segment}`)
if (agentId) goTo(agentId, segment)
},
[navigate, agentId],
[goTo, agentId],
)
const changeView = useCallback(
(next: PersonnelView | null) => {
if (!next) return
const params = new URLSearchParams(searchParams)
params.set(VIEW_PARAM, next)
setSearchParams(params, { replace: false })
},
[searchParams, setSearchParams],
)
const dossier = selectedAgent && (
<AgentDossier
agent={selectedAgent}
activeSegment={activeSegment}
onSegmentChange={selectSegment}
/>
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
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 ? (
<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' }}>
{/* Ebene 2: Agentenliste */}
<AgentListPanel
agents={agents}
selectedId={selectedAgent?.id ?? null}
selectedId={selectedAgent.id}
onSelect={selectAgent}
compact={isCompact}
/>
{/* Ebene 3: Personaldossier */}
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page, minWidth: 0 }}>
{isLoading && <PanelLoadingState />}
{!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>
</>
)}
{dossier}
</Box>
</Box>
)}
+29 -60
View File
@@ -1,89 +1,58 @@
import { useCallback } from 'react'
import { useNavigate } from 'react-router'
import { Box, Button } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import { useState } from 'react'
import { Box } from '@mui/material'
import {
TeamPageHeader,
TeamSectionHeader,
TeamKpiSection,
AgentCard,
TeamLevelTabs,
TeamOverviewRing,
ConnectionSummaryList,
} from '../../components/team'
import { CardSkeleton, ErrorState } from '../../components/ui'
import { ErrorState } from '../../components/ui'
import type { AgentLevel } from '../../domain/agentDirectory'
import { AgentLevel as Level } from '../../domain/agentDirectory'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import { useAgentConnections } from '../../hooks/useAgentConnections'
import { useResetTeamDemo } from '../../hooks/useTeamDemo'
import { ROUTES } from '../../lib/constants'
/**
* Startseite des Property-On-Bereichs.
* Verbindliche Reihenfolge: Auswertung, Kernteam, Kanäle & Systeme (§4.1).
*
* Aufbau: die Belegschaft als Kreis direkt unter dem Seitentitel, darunter die
* Auswertung und die Verbindungen. Der Kreis steht bewusst zuoberst er ist die
* Antwort auf die Frage, mit der ein Bewirtschafter die Seite öffnet: «wer
* arbeitet hier eigentlich für mich?»
*
* Kopfbereich bewusst ohne Beschreibungstext und ohne «Demo zurücksetzen»: der
* Platz gehört der Visualisierung.
*/
export default function Teamuebersicht() {
const navigate = useNavigate()
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
const [level, setLevel] = useState<AgentLevel>(Level.CORE)
const { data: agents = [], isError, refetch } = useTeamAgents()
const { data: connections = [] } = useAgentConnections()
const resetDemo = useResetTeamDemo()
const openAgent = useCallback(
(agentId: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}`),
[navigate],
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
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>
}
/>
<TeamPageHeader title="Teamübersicht" />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 5 }}>
{isError ? (
<ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} />
) : (
<>
<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 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">
<Box component="section" sx={{ mt: 6 }}>
<TeamKpiSection agents={agents} />
</Box>
<Box component="section" sx={{ mt: 2 }}>
<TeamSectionHeader
title="Kanäle & Systeme"
description="Womit das digitale Team heute verbunden ist."
+18
View File
@@ -10,3 +10,21 @@ import { cleanup } from '@testing-library/react'
* Datei an «found multiple elements».
*/
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
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
// Ohne diese Referenz kennt TypeScript die von Vite bereitgestellten
// Modul-Deklarationen für Assets nicht — ein `import portrait from './x.jpg'`
// würde als Fehler gemeldet, obwohl der Bundler es korrekt auflöst.
+5
View File
@@ -7,6 +7,11 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
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: {
provider: 'v8',
reporter: ['text', 'html'],