Compare commits
3 Commits
a7e05fb7f0
...
Agenten
| Author | SHA1 | Date | |
|---|---|---|---|
| 177641386a | |||
| 8377ce03b5 | |||
| 576733d7da |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Box, Tooltip, Typography } from '@mui/material'
|
||||
import { agentDirectory } from '../../mock-data/agentDirectory'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const MAX_VISIBLE = 5
|
||||
|
||||
interface Props {
|
||||
agentIds: string[]
|
||||
size?: number
|
||||
/** Text vor der Gruppe, z. B. «Genutzt durch». */
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Überlappende Porträtgruppe (§2.3, §7.1).
|
||||
*
|
||||
* Ersetzt die frühere Namensliste: wer ein System nutzt, ist als Gesicht
|
||||
* schneller erfasst als als Aufzählung. Ab dem sechsten Eintrag steht ein
|
||||
* Zähler, sonst franst die Gruppe in schmalen Karten aus.
|
||||
*
|
||||
* Die Namen bleiben über Tooltip und `aria-label` erreichbar — die Information
|
||||
* darf nicht ausschliesslich im Bild stecken.
|
||||
*/
|
||||
export const AgentAvatarGroup = memo(function AgentAvatarGroup({
|
||||
agentIds,
|
||||
size = 24,
|
||||
label,
|
||||
}: Props) {
|
||||
const { visible, overflow, allNames } = useMemo(() => {
|
||||
const entries = agentIds
|
||||
.map(id => agentDirectory.find(a => a.id === id))
|
||||
.filter((a): a is NonNullable<typeof a> => !!a)
|
||||
return {
|
||||
visible: entries.slice(0, MAX_VISIBLE),
|
||||
overflow: entries.slice(MAX_VISIBLE),
|
||||
allNames: entries.map(a => a.name).join(', '),
|
||||
}
|
||||
}, [agentIds])
|
||||
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
Noch keinem Mitarbeitenden zugewiesen
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||
{label && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, flexShrink: 0 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box
|
||||
role="list"
|
||||
aria-label={allNames}
|
||||
sx={{ display: 'flex', alignItems: 'center', pl: 0.5 }}
|
||||
>
|
||||
{visible.map((agent) => (
|
||||
<Tooltip key={agent.id} title={`${agent.name} · ${agent.role}`} arrow>
|
||||
<Box
|
||||
role="listitem"
|
||||
sx={{
|
||||
ml: -0.5,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${DS_BG.surface}`,
|
||||
display: 'flex',
|
||||
transition: 'transform 0.15s ease',
|
||||
'&:hover': { transform: 'translateY(-2px)', zIndex: 1 },
|
||||
}}
|
||||
>
|
||||
<AgentAvatar agent={agent} size={size} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{overflow.length > 0 && (
|
||||
<Tooltip title={overflow.map(a => a.name).join(', ')} arrow>
|
||||
<Box
|
||||
role="listitem"
|
||||
sx={{
|
||||
ml: -0.5,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${DS_BG.surface}`,
|
||||
bgcolor: DS_BORDER.muted,
|
||||
color: DS_TEXT.secondary,
|
||||
fontSize: size * 0.36,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
+{overflow.length}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import type { TeamAgent } from '../../domain/teamAgent'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges'
|
||||
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
|
||||
import { formatTeamRelative } from '../../lib/teamClock'
|
||||
|
||||
interface Props {
|
||||
agent: TeamAgent
|
||||
onOpen: (agentId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Agentenkarte im Kernteam-Raster (§6.2).
|
||||
*
|
||||
* Der Aktivstatus steht doppelt: als Haken am Avatar und als Textbadge. Farbe
|
||||
* allein darf einen Zustand nie tragen (§2.3, §18).
|
||||
*/
|
||||
export const AgentCard = memo(function AgentCard({ agent, onOpen }: Props) {
|
||||
const headline = agent.metrics.find(m => m.id === agent.headlineMetricId) ?? agent.metrics[0]
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="article"
|
||||
sx={{
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
borderRadius: 2,
|
||||
bgcolor: DS_BG.surface,
|
||||
boxShadow: DS_SHADOW.card,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.25,
|
||||
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
|
||||
'&:hover': { borderColor: DS_TEXT.brand, boxShadow: DS_SHADOW.panel },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AgentAvatar agent={agent} size="medium" showStatus />
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary, lineHeight: 1.3 }}>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
{agent.role}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
Personalnummer {agent.personnelNumber}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, flex: 1 }}>
|
||||
{agent.shortDescription}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
<AgentAutonomyBadge autonomy={agent.autonomyLevel} note={agent.autonomyNote} />
|
||||
</Box>
|
||||
|
||||
{headline && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 0.75,
|
||||
pt: 1,
|
||||
borderTop: `1px solid ${DS_BORDER.muted}`,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', color: DS_TEXT.primary }}>
|
||||
{headline.value}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{headline.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{agent.lastRun ? `Zuletzt aktiv ${formatTeamRelative(agent.lastRun)}` : 'Noch nicht gelaufen'}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowRight size={14} />}
|
||||
onClick={() => onOpen(agent.id)}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Personalblatt öffnen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
|
||||
import type { ArcOptions, RingSpec } from './agentRingConfig'
|
||||
import { layoutArc } from './agentRingConfig'
|
||||
import { AgentRingNode } from './AgentRingNode'
|
||||
import { DS_BORDER } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
agents: AgentDirectoryEntry[]
|
||||
spec: RingSpec
|
||||
scale: number
|
||||
arc?: ArcOptions
|
||||
selectedId?: string | null
|
||||
onSelect: (agent: AgentDirectoryEntry) => void
|
||||
/** Führungslinie hinter den Portraits — die Referenzgrafik zeigt sie. */
|
||||
showGuide?: boolean
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Ring der Kreisdarstellung.
|
||||
*
|
||||
* Rechnet selbst keine Positionen aus, sondern bezieht sie aus `layoutArc` —
|
||||
* so teilen sich der volle Kreis der Teamübersicht und der Halbkreis des
|
||||
* Organigramms dieselbe Geometrie.
|
||||
*/
|
||||
export const AgentRing = memo(function AgentRing({
|
||||
agents,
|
||||
spec,
|
||||
scale,
|
||||
arc,
|
||||
selectedId,
|
||||
onSelect,
|
||||
showGuide = true,
|
||||
visible = true,
|
||||
}: Props) {
|
||||
const positions = useMemo(
|
||||
() => layoutArc(agents.length, spec.radius, arc),
|
||||
[agents.length, spec.radius, arc],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box
|
||||
aria-hidden={!visible}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? 'scale(1)' : 'scale(0.94)',
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
transition: 'opacity 0.35s ease, transform 0.35s ease',
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'opacity 0.01s linear', transform: 'none' },
|
||||
}}
|
||||
>
|
||||
{showGuide && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: `${spec.radius * 100}%`,
|
||||
height: `${spec.radius * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
borderRadius: '50%',
|
||||
border: spec.dashed
|
||||
? `1.5px dashed ${DS_BORDER.strong}`
|
||||
: `1px solid ${DS_BORDER.muted}`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{agents.map((agent, index) => {
|
||||
const position = positions[index]
|
||||
if (!position) return null
|
||||
return (
|
||||
<AgentRingNode
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
spec={spec}
|
||||
scale={scale}
|
||||
leftPct={position.leftPct}
|
||||
topPct={position.topPct}
|
||||
selected={agent.id === selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { Box, Tooltip, Typography } from '@mui/material'
|
||||
import type { AgentDirectoryEntry } from '../../domain/agentDirectory'
|
||||
import type { RingSpec } from './agentRingConfig'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { BADGE_COLORS, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
agent: AgentDirectoryEntry
|
||||
spec: RingSpec
|
||||
/** Skalierungsfaktor gegenüber der Referenzbreite. */
|
||||
scale: number
|
||||
leftPct: number
|
||||
topPct: number
|
||||
selected?: boolean
|
||||
onSelect: (agent: AgentDirectoryEntry) => void
|
||||
}
|
||||
|
||||
const MIN_AVATAR_PX = 30
|
||||
|
||||
/**
|
||||
* Ein Portrait auf dem Ring.
|
||||
*
|
||||
* Die Funktionsbezeichnung steht dauerhaft nur beim Kernteam; auf den äusseren
|
||||
* Ringen erscheint sie bei Hover oder Tastaturfokus. Alles dauerhaft zu
|
||||
* beschriften liesse die Beschriftungen überlappen — die Referenzgrafik löst es
|
||||
* genauso.
|
||||
*
|
||||
* Der Tooltip trägt Name und Funktion immer, damit die Information nie
|
||||
* ausschliesslich im Bild steckt.
|
||||
*/
|
||||
export const AgentRingNode = memo(function AgentRingNode({
|
||||
agent,
|
||||
spec,
|
||||
scale,
|
||||
leftPct,
|
||||
topPct,
|
||||
selected = false,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const [active, setActive] = useState(false)
|
||||
const px = Math.max(MIN_AVATAR_PX, Math.round(spec.avatarPx * scale))
|
||||
const showRole = spec.showRole || active || selected
|
||||
const labelWidth = Math.round(px * 2.1)
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-label={`${agent.name}, ${agent.role}`}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
onClick={() => onSelect(agent)}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
onFocus={() => setActive(true)}
|
||||
onBlur={() => setActive(false)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: `${leftPct}%`,
|
||||
top: `${topPct}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.375,
|
||||
width: labelWidth,
|
||||
p: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
font: 'inherit',
|
||||
cursor: 'pointer',
|
||||
zIndex: active || selected ? 3 : 1,
|
||||
transition: 'transform 0.2s ease',
|
||||
'&:hover, &:focus-visible': { transform: 'translate(-50%, -50%) scale(1.06)' },
|
||||
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 4, borderRadius: 2 },
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:hover, &:focus-visible': { transform: 'translate(-50%, -50%)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title={`${agent.name} · ${agent.role}`} arrow enterDelay={200}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
p: spec.dashed ? '2px' : 0,
|
||||
border: spec.dashed ? `1.5px dashed ${DS_BORDER.strong}` : 'none',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<AgentAvatar
|
||||
agent={agent}
|
||||
size={px}
|
||||
ringColor={spec.level === 'CORE' ? BADGE_COLORS.gold : DS_BORDER.default}
|
||||
ringWidth={spec.level === 'CORE' ? 2.5 : 1.5}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: `${Math.max(9.5, 11 * scale)}px`,
|
||||
lineHeight: 1.15,
|
||||
color: DS_TEXT.primary,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
|
||||
{/* Höhe wird reserviert, damit die Ringe beim Hover nicht springen */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: `${Math.max(8.5, 9.5 * scale)}px`,
|
||||
lineHeight: 1.15,
|
||||
color: DS_TEXT.secondary,
|
||||
textAlign: 'center',
|
||||
opacity: showRole ? 1 : 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
}}
|
||||
>
|
||||
{agent.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Box, Tab, Tabs, Typography } from '@mui/material'
|
||||
import type { AgentLevel } from '../../domain/agentDirectory'
|
||||
import { AGENT_LEVEL_ORDER } from '../../domain/agentDirectory'
|
||||
import { agentDirectory } from '../../mock-data/agentDirectory'
|
||||
import { AGENT_LEVEL_TAB_LABELS } from '../../lib/constants'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
value: AgentLevel
|
||||
onChange: (level: AgentLevel) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Filterreiter über der Kreisdarstellung.
|
||||
*
|
||||
* Die Stufen sind kumulativ: «+ Zentrale Dienste» zeigt Kernteam UND Zentrale
|
||||
* Dienste. Deshalb steht neben jedem Reiter die Gesamtzahl der dann sichtbaren
|
||||
* Mitarbeitenden und nicht die Zahl der Stufe allein — sonst läse sich «+ GB
|
||||
* Kommerziell · 7» so, als verschwänden die inneren Ringe.
|
||||
*
|
||||
* Bezeichnungen stammen wörtlich aus dem Konzeptdokument. Keine neuen
|
||||
* Stufennamen erfunden.
|
||||
*/
|
||||
export function TeamLevelTabs({ value, onChange }: Props) {
|
||||
const cumulativeCounts = useMemo(() => {
|
||||
const counts = new Map<AgentLevel, number>()
|
||||
let running = 0
|
||||
for (const level of AGENT_LEVEL_ORDER) {
|
||||
running += agentDirectory.filter(a => a.level === level).length
|
||||
counts.set(level, running)
|
||||
}
|
||||
return counts
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={(_, next: AgentLevel) => onChange(next)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
aria-label="Personalstufen"
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
minHeight: 40,
|
||||
py: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{AGENT_LEVEL_ORDER.map((level) => (
|
||||
<Tab
|
||||
key={level}
|
||||
value={level}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<span>{AGENT_LEVEL_TAB_LABELS[level] ?? level}</span>
|
||||
<Typography component="span" variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{cumulativeCounts.get(level)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Property On — Geometrie der Kreisdarstellung.
|
||||
*
|
||||
* Einziger Ort, an dem Radien, Winkel und Portraitgrössen stehen. Die
|
||||
* Komponenten rechnen selbst nichts aus und tragen keine Positionswerte —
|
||||
* verteilte Positionsdaten in mehreren Komponenten wären beim ersten
|
||||
* Layoutwechsel nicht mehr konsistent zu halten.
|
||||
*
|
||||
* Alle Radien sind relativ zur halben Containerbreite (0…1), damit die
|
||||
* Darstellung ohne Umrechnung mitskaliert.
|
||||
*/
|
||||
|
||||
import { AgentLevel } from '../../domain/agentDirectory'
|
||||
|
||||
export interface RingSpec {
|
||||
level: AgentLevel
|
||||
/** Anteil der halben Containerkante, auf dem die Portraits sitzen. */
|
||||
radius: number
|
||||
/** Portraitdurchmesser in Pixeln bei Referenzbreite `RING_REFERENCE_PX`. */
|
||||
avatarPx: number
|
||||
/** Funktion dauerhaft anzeigen — sonst erst bei Fokus oder im Tooltip. */
|
||||
showRole: boolean
|
||||
/** Gestrichelter Rahmen: Stufe befindet sich noch im Aufbau. */
|
||||
dashed: boolean
|
||||
}
|
||||
|
||||
/** Breite, für die die Pixelwerte gedacht sind; darunter wird linear skaliert. */
|
||||
export const RING_REFERENCE_PX = 940
|
||||
|
||||
export const RING_SPECS: RingSpec[] = [
|
||||
{ level: AgentLevel.CORE, radius: 0.30, avatarPx: 76, showRole: true, dashed: false },
|
||||
{ level: AgentLevel.CENTRAL_SERVICES, radius: 0.52, avatarPx: 54, showRole: false, dashed: false },
|
||||
{ level: AgentLevel.COMMERCIAL, radius: 0.72, avatarPx: 54, showRole: false, dashed: false },
|
||||
{ level: AgentLevel.RESIDENTIAL, radius: 0.90, avatarPx: 50, showRole: false, dashed: true },
|
||||
]
|
||||
|
||||
export function ringSpecFor(level: AgentLevel): RingSpec {
|
||||
return RING_SPECS.find(s => s.level === level) ?? RING_SPECS[RING_SPECS.length - 1]
|
||||
}
|
||||
|
||||
export interface RingNodePosition {
|
||||
/** Position in Prozent der Containerkante, bezogen auf die Mitte. */
|
||||
leftPct: number
|
||||
topPct: number
|
||||
angleDeg: number
|
||||
}
|
||||
|
||||
export interface ArcOptions {
|
||||
/** Startwinkel in Grad; 0 = oben, im Uhrzeigersinn wachsend. */
|
||||
startDeg?: number
|
||||
/** Überstrichener Winkel. 360 = voller Kreis, 180 = Halbkreis. */
|
||||
sweepDeg?: number
|
||||
/**
|
||||
* Bei geschlossenem Kreis liegen Anfang und Ende aufeinander, deshalb wird
|
||||
* durch `count` geteilt. Bei einem offenen Bogen soll der letzte Eintrag am
|
||||
* Endwinkel stehen, deshalb durch `count - 1`.
|
||||
*/
|
||||
closed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Verteilt `count` Knoten gleichmässig auf einem Bogen.
|
||||
* Rückgabe in Prozent, damit die Darstellung containerrelativ bleibt.
|
||||
*/
|
||||
export function layoutArc(
|
||||
count: number,
|
||||
radius: number,
|
||||
{ startDeg = 0, sweepDeg = 360, closed = true }: ArcOptions = {},
|
||||
): RingNodePosition[] {
|
||||
if (count <= 0) return []
|
||||
if (count === 1) {
|
||||
const rad = ((startDeg - 90) * Math.PI) / 180
|
||||
return [{
|
||||
leftPct: 50 + Math.cos(rad) * radius * 50,
|
||||
topPct: 50 + Math.sin(rad) * radius * 50,
|
||||
angleDeg: startDeg,
|
||||
}]
|
||||
}
|
||||
|
||||
const divisor = closed ? count : count - 1
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angleDeg = startDeg + (sweepDeg * i) / divisor
|
||||
// -90°, damit 0° oben liegt statt rechts
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180
|
||||
return {
|
||||
leftPct: 50 + Math.cos(rad) * radius * 50,
|
||||
topPct: 50 + Math.sin(rad) * radius * 50,
|
||||
angleDeg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Winkel der aktiven Position im Organigramm-Halbkreis: unten mittig.
|
||||
* Der Halbkreis wird oben abgeschnitten, unten steht der gewählte Agent.
|
||||
*/
|
||||
export const WHEEL_ACTIVE_ANGLE_DEG = 180
|
||||
|
||||
/** Bogen, auf dem das Agentenrad seine Portraits verteilt. */
|
||||
export const WHEEL_ARC = {
|
||||
startDeg: 90,
|
||||
sweepDeg: 180,
|
||||
} as const
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
export { AgentAvatar } from './AgentAvatar'
|
||||
export 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'
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Property On — Verzeichnis der digitalen Belegschaft.
|
||||
*
|
||||
* Ergänzt `teamAgent.ts`, ersetzt es nicht: `TeamAgent` ist das vollständige
|
||||
* Personaldossier der sieben Kernteammitglieder, `AgentDirectoryEntry` sind die
|
||||
* Stammdaten aller 36 Mitarbeitenden für die Kreisdarstellung.
|
||||
*
|
||||
* Die Stufenbezeichnungen sind aus dem Konzeptdokument übernommen und nicht neu
|
||||
* erfunden. Sie sind kumulativ zu lesen — «+ Zentrale Dienste» meint Kernteam
|
||||
* plus Zentrale Dienste, nicht Zentrale Dienste allein.
|
||||
*/
|
||||
|
||||
/** Personalstufe — bestimmt zugleich den Ring in der Kreisdarstellung. */
|
||||
export const AgentLevel = {
|
||||
CORE: 'CORE',
|
||||
CENTRAL_SERVICES: 'CENTRAL_SERVICES',
|
||||
COMMERCIAL: 'COMMERCIAL',
|
||||
RESIDENTIAL: 'RESIDENTIAL',
|
||||
} as const
|
||||
export type AgentLevel = typeof AgentLevel[keyof typeof AgentLevel]
|
||||
|
||||
/** Ringreihenfolge von innen nach aussen. */
|
||||
export const AGENT_LEVEL_ORDER: AgentLevel[] = [
|
||||
AgentLevel.CORE,
|
||||
AgentLevel.CENTRAL_SERVICES,
|
||||
AgentLevel.COMMERCIAL,
|
||||
AgentLevel.RESIDENTIAL,
|
||||
]
|
||||
|
||||
export const AgentDepartment = {
|
||||
CENTRAL_SERVICES: 'CENTRAL_SERVICES',
|
||||
COMMERCIAL: 'COMMERCIAL',
|
||||
RESIDENTIAL: 'RESIDENTIAL',
|
||||
} as const
|
||||
export type AgentDepartment = typeof AgentDepartment[keyof typeof AgentDepartment]
|
||||
|
||||
/** Aufbaustand aus dem Konzept — nicht zu verwechseln mit `AgentStatus` (aktiv/pausiert). */
|
||||
export const AgentRollout = {
|
||||
PROTOTYPE: 'PROTOTYPE',
|
||||
IN_PROGRESS: 'IN_PROGRESS',
|
||||
PLANNED: 'PLANNED',
|
||||
} as const
|
||||
export type AgentRollout = typeof AgentRollout[keyof typeof AgentRollout]
|
||||
|
||||
export interface AgentDirectoryEntry {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
email: string
|
||||
level: AgentLevel
|
||||
department: AgentDepartment
|
||||
rollout: AgentRollout
|
||||
/** true → vollständiges Personaldossier vorhanden. */
|
||||
isCore: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimalform für die Avatardarstellung. Bewusst schmal, damit sowohl ein
|
||||
* vollständiger `TeamAgent` als auch ein blosser Verzeichniseintrag hineinpasst
|
||||
* — sonst bräuchte es zwei fast identische Avatarkomponenten.
|
||||
*/
|
||||
export interface AgentAvatarSubject {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
@@ -330,6 +330,40 @@ export const AGENT_PERIOD_LABELS: Record<string, string> = {
|
||||
ALL: 'Gesamter Zeitraum',
|
||||
}
|
||||
|
||||
/**
|
||||
* Personalstufen der digitalen Belegschaft.
|
||||
*
|
||||
* Wortlaut unverändert aus dem Konzeptdokument übernommen — die Reiter sind
|
||||
* kumulativ zu lesen: «+ Zentrale Dienste» meint Kernteam plus Zentrale Dienste.
|
||||
*/
|
||||
export const AGENT_LEVEL_LABELS: Record<string, string> = {
|
||||
CORE: 'Kernteam',
|
||||
CENTRAL_SERVICES: 'Zentrale Dienste',
|
||||
COMMERCIAL: 'GB Kommerzielle Immobilien',
|
||||
RESIDENTIAL: 'GB Wohnen',
|
||||
}
|
||||
|
||||
/** Beschriftung der Filterreiter — ebenfalls Wortlaut des Konzepts. */
|
||||
export const AGENT_LEVEL_TAB_LABELS: Record<string, string> = {
|
||||
CORE: 'Kernteam',
|
||||
CENTRAL_SERVICES: '+ Zentrale Dienste',
|
||||
COMMERCIAL: '+ GB Kommerziell',
|
||||
RESIDENTIAL: 'Alle 36',
|
||||
}
|
||||
|
||||
export const AGENT_DEPARTMENT_LABELS: Record<string, string> = {
|
||||
CENTRAL_SERVICES: 'Zentrale Dienste',
|
||||
COMMERCIAL: 'GB Kommerzielle Immobilien',
|
||||
RESIDENTIAL: 'GB Wohnen',
|
||||
}
|
||||
|
||||
/** Aufbaustand aus dem Konzept — nicht der Betriebszustand aktiv/pausiert. */
|
||||
export const AGENT_ROLLOUT_LABELS: Record<string, string> = {
|
||||
PROTOTYPE: 'Prototyp läuft',
|
||||
IN_PROGRESS: 'Im Aufbau',
|
||||
PLANNED: 'Geplant',
|
||||
}
|
||||
|
||||
/** Simulierte Ladezeit für Demo-Aktionen (§19.1: 300–800 ms). */
|
||||
export const AGENT_SIM_DELAY_MS = 420
|
||||
export const AGENT_SIM_DELAY_SLOW_MS = 760
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Property On — Verzeichnis aller digitalen Mitarbeitenden.
|
||||
*
|
||||
* Quelle ist das Konzeptdokument «Agenten_Bewirtschaftung_v2.html» (Stand
|
||||
* 24.07.2026): Namen, Funktionen, Personalstufen, Bereiche und Rollout-Status
|
||||
* sind daraus unverändert übernommen, ebenso die Porträts.
|
||||
*
|
||||
* Abgrenzung zu `mockTeamAgents`: dieses Verzeichnis trägt nur die Stammdaten,
|
||||
* die für die Kreisdarstellung nötig sind. Ein vollständiges Personaldossier —
|
||||
* Aufgaben, Kanäle, Systemzugänge, Einstellungen, Protokoll — besitzen
|
||||
* ausschliesslich die sieben Kernteammitglieder in `src/mock-data/agents/`.
|
||||
* Für die übrigen 29 werden bewusst keine Inhalte erfunden.
|
||||
*
|
||||
* ACHTUNG: generiert aus dem Konzept. Nicht von Hand pflegen — bei einer neuen
|
||||
* Konzeptfassung neu erzeugen, sonst driften Verzeichnis und Quelle auseinander.
|
||||
*/
|
||||
|
||||
import type { AgentDirectoryEntry } from '../domain/agentDirectory'
|
||||
import { AgentLevel, AgentDepartment, AgentRollout } from '../domain/agentDirectory'
|
||||
|
||||
export const agentDirectory: AgentDirectoryEntry[] = [
|
||||
{
|
||||
id: 'bruno',
|
||||
name: 'Bruno',
|
||||
role: 'Besichtigungs-Briefing',
|
||||
email: 'bruno@property-on.ch',
|
||||
level: AgentLevel.CORE,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: 'ferdi',
|
||||
name: 'Ferdi',
|
||||
role: 'Fristen-Wächter',
|
||||
email: 'ferdi@property-on.ch',
|
||||
level: AgentLevel.CORE,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PROTOTYPE,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: 'lea',
|
||||
name: 'Lea',
|
||||
role: 'Anfragen-Manager',
|
||||
email: 'lea@property-on.ch',
|
||||
level: AgentLevel.CORE,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PROTOTYPE,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: 'livia',
|
||||
name: 'Livia',
|
||||
role: 'Lage-Analyst',
|
||||
email: 'livia@property-on.ch',
|
||||
level: AgentLevel.CORE,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: 'nora',
|
||||
name: 'Nora',
|
||||
role: 'Markt-Scout',
|
||||
email: 'nora@property-on.ch',
|
||||
level: AgentLevel.CORE,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PROTOTYPE,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: 'reto',
|
||||
name: 'Reto',
|
||||
role: 'Recap-Agent',
|
||||
email: 'reto@property-on.ch',
|
||||
level: AgentLevel.CORE,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: 'sina',
|
||||
name: 'Sina',
|
||||
role: 'Vertragsauskunft',
|
||||
email: 'sina@property-on.ch',
|
||||
level: AgentLevel.CORE,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: 'ada',
|
||||
name: 'Ada',
|
||||
role: 'Datenqualitäts-Wächterin',
|
||||
email: 'ada@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PROTOTYPE,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'carla',
|
||||
name: 'Carla',
|
||||
role: 'Bestands-Radar',
|
||||
email: 'carla@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'diego',
|
||||
name: 'Diego',
|
||||
role: 'Decision-Brief-Ersteller',
|
||||
email: 'diego@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PROTOTYPE,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'dora',
|
||||
name: 'Dora',
|
||||
role: 'Dossier-Erstellerin',
|
||||
email: 'dora@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'emil',
|
||||
name: 'Emil',
|
||||
role: 'Unterlagen-Versand',
|
||||
email: 'emil@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'filip',
|
||||
name: 'Filip',
|
||||
role: 'Ablage-Agent',
|
||||
email: 'filip@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'kira',
|
||||
name: 'Kira',
|
||||
role: 'Konkurrenz-Beobachterin',
|
||||
email: 'kira@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'lars',
|
||||
name: 'Lars',
|
||||
role: 'Archiv-Digitalisierer',
|
||||
email: 'lars@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'mia',
|
||||
name: 'Mia',
|
||||
role: 'Mietpreis-Benchmarkerin',
|
||||
email: 'mia@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'paul',
|
||||
name: 'Paul',
|
||||
role: 'Pipeline-Führer',
|
||||
email: 'paul@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'pia',
|
||||
name: 'Pia',
|
||||
role: 'Mietantritt-Koordinatorin',
|
||||
email: 'pia@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'rea',
|
||||
name: 'Rea',
|
||||
role: 'Referenz-Einholerin',
|
||||
email: 'rea@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'rico',
|
||||
name: 'Rico',
|
||||
role: 'Eigentümer-Reporter',
|
||||
email: 'rico@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'uli',
|
||||
name: 'Uli',
|
||||
role: 'Übergabe-Assistent',
|
||||
email: 'uli@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'vela',
|
||||
name: 'Vela',
|
||||
role: 'Cockpit-Agentin',
|
||||
email: 'vela@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'vera',
|
||||
name: 'Vera',
|
||||
role: 'Inserat-Texterin',
|
||||
email: 'vera@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'vinz',
|
||||
name: 'Vinz',
|
||||
role: 'Vertragsentwurf-Assistent',
|
||||
email: 'vinz@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'vito',
|
||||
name: 'Vito',
|
||||
role: 'Objekt-Erfasser',
|
||||
email: 'vito@property-on.ch',
|
||||
level: AgentLevel.CENTRAL_SERVICES,
|
||||
department: AgentDepartment.CENTRAL_SERVICES,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'beat',
|
||||
name: 'Beat',
|
||||
role: 'Zahlungs-Frühwarner',
|
||||
email: 'beat@property-on.ch',
|
||||
level: AgentLevel.COMMERCIAL,
|
||||
department: AgentDepartment.COMMERCIAL,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'fabio',
|
||||
name: 'Fabio',
|
||||
role: 'Fit-out-Rechner',
|
||||
email: 'fabio@property-on.ch',
|
||||
level: AgentLevel.COMMERCIAL,
|
||||
department: AgentDepartment.COMMERCIAL,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'nadia',
|
||||
name: 'Nadia',
|
||||
role: 'Umsatzmiete-Abrechnerin',
|
||||
email: 'nadia@property-on.ch',
|
||||
level: AgentLevel.COMMERCIAL,
|
||||
department: AgentDepartment.COMMERCIAL,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'oskar',
|
||||
name: 'Oskar',
|
||||
role: 'Offerten-Schreiber',
|
||||
email: 'oskar@property-on.ch',
|
||||
level: AgentLevel.COMMERCIAL,
|
||||
department: AgentDepartment.COMMERCIAL,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'otto',
|
||||
name: 'Otto',
|
||||
role: 'Options-Vorbereiter',
|
||||
email: 'otto@property-on.ch',
|
||||
level: AgentLevel.COMMERCIAL,
|
||||
department: AgentDepartment.COMMERCIAL,
|
||||
rollout: AgentRollout.PLANNED,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'sven',
|
||||
name: 'Sven',
|
||||
role: 'Mieter-Check',
|
||||
email: 'sven@property-on.ch',
|
||||
level: AgentLevel.COMMERCIAL,
|
||||
department: AgentDepartment.COMMERCIAL,
|
||||
rollout: AgentRollout.PROTOTYPE,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'tino',
|
||||
name: 'Tino',
|
||||
role: 'Mietzins-Rechner',
|
||||
email: 'tino@property-on.ch',
|
||||
level: AgentLevel.COMMERCIAL,
|
||||
department: AgentDepartment.COMMERCIAL,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'gian',
|
||||
name: 'Gian',
|
||||
role: 'Privat-Check',
|
||||
email: 'gian@property-on.ch',
|
||||
level: AgentLevel.RESIDENTIAL,
|
||||
department: AgentDepartment.RESIDENTIAL,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'ida',
|
||||
name: 'Ida',
|
||||
role: 'Nebenkosten-Assistentin',
|
||||
email: 'ida@property-on.ch',
|
||||
level: AgentLevel.RESIDENTIAL,
|
||||
department: AgentDepartment.RESIDENTIAL,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'jana',
|
||||
name: 'Jana',
|
||||
role: 'Wechsel-Koordinatorin Wohnen',
|
||||
email: 'jana@property-on.ch',
|
||||
level: AgentLevel.RESIDENTIAL,
|
||||
department: AgentDepartment.RESIDENTIAL,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: 'zeno',
|
||||
name: 'Zeno',
|
||||
role: 'Referenzzins-Spezialist',
|
||||
email: 'zeno@property-on.ch',
|
||||
level: AgentLevel.RESIDENTIAL,
|
||||
department: AgentDepartment.RESIDENTIAL,
|
||||
rollout: AgentRollout.IN_PROGRESS,
|
||||
isCore: false,
|
||||
},
|
||||
]
|
||||
|
||||
/** Nur die sieben Kernteammitglieder — sie besitzen ein vollständiges Dossier. */
|
||||
export const coreAgentIds: string[] = agentDirectory.filter(a => a.isCore).map(a => a.id)
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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'],
|
||||
|
||||