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 pageName = getPageNameFromPath(pathname)
|
||||||
const openAssistant = useAssistantStore(s => s.open)
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="header"
|
component="header"
|
||||||
@@ -48,24 +53,28 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
|
|||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<Chip
|
{showBreadcrumb && (
|
||||||
label={config.label}
|
<>
|
||||||
size="small"
|
<Chip
|
||||||
variant="outlined"
|
label={config.label}
|
||||||
sx={{
|
size="small"
|
||||||
color: '#152642',
|
variant="outlined"
|
||||||
fontWeight: 600,
|
sx={{
|
||||||
fontSize: '0.7rem',
|
color: '#152642',
|
||||||
height: 22,
|
fontWeight: 600,
|
||||||
border: '1px solid #e8e7e4',
|
fontSize: '0.7rem',
|
||||||
}}
|
height: 22,
|
||||||
/>
|
border: '1px solid #e8e7e4',
|
||||||
<Typography
|
}}
|
||||||
variant="body1"
|
/>
|
||||||
sx={{ fontWeight: 500, color: '#1e293b', fontSize: { xs: '0.85rem', sm: '0.9375rem' } }}
|
<Typography
|
||||||
>
|
variant="body1"
|
||||||
{pageName}
|
sx={{ fontWeight: 500, color: '#1e293b', fontSize: { xs: '0.85rem', sm: '0.9375rem' } }}
|
||||||
</Typography>
|
>
|
||||||
|
{pageName}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
|
|||||||
@@ -1,66 +1,97 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Avatar, Box, Tooltip } from '@mui/material'
|
import { Avatar, Box, Tooltip } from '@mui/material'
|
||||||
import { Check } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { AgentAvatarSubject } from '../../domain/agentDirectory'
|
||||||
import { AgentStatus } from '../../domain/teamAgent'
|
import type { AgentStatus, AgentAvatarTone } from '../../domain/teamAgent'
|
||||||
|
import { AgentStatus as Status } from '../../domain/teamAgent'
|
||||||
import { AVATAR_TONE_COLOR } from './teamTokens'
|
import { AVATAR_TONE_COLOR } from './teamTokens'
|
||||||
|
import { AGENT_PHOTOS } from './agentPhotos'
|
||||||
import { AGENT_STATUS_LABELS } from '../../lib/constants'
|
import { AGENT_STATUS_LABELS } from '../../lib/constants'
|
||||||
import { DS_BG, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_TEXT, DS_BORDER } from '../../lib/ds'
|
||||||
|
|
||||||
const SIZES = {
|
const NAMED_SIZES = {
|
||||||
|
xsmall: 22,
|
||||||
small: 28,
|
small: 28,
|
||||||
medium: 40,
|
medium: 40,
|
||||||
large: 64,
|
large: 64,
|
||||||
|
xlarge: 96,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type AgentAvatarSize = keyof typeof SIZES
|
export type AgentAvatarSize = keyof typeof NAMED_SIZES | number
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: TeamAgent
|
|
||||||
size?: AgentAvatarSize
|
|
||||||
/**
|
/**
|
||||||
* Blendet den Statuspunkt ein. Auf der Teamübersicht ist er verbindlich (§6.2):
|
* Bewusst die Minimalform: so passt sowohl ein vollständiger `TeamAgent` als
|
||||||
* ein grüner Haken für aktiv, sonst der Hinweis «Pausiert».
|
* auch ein blosser Verzeichniseintrag hinein. Zwei fast identische
|
||||||
|
* Avatarkomponenten für dieselbe Sache wären reine Duplikation.
|
||||||
*/
|
*/
|
||||||
|
agent: AgentAvatarSubject
|
||||||
|
size?: AgentAvatarSize
|
||||||
|
/** Nur die sieben Kernteammitglieder führen einen Betriebszustand. */
|
||||||
|
status?: AgentStatus
|
||||||
showStatus?: boolean
|
showStatus?: boolean
|
||||||
|
/** Farbe der Initialen-Fläche, falls kein Porträt vorhanden ist. */
|
||||||
|
tone?: AgentAvatarTone
|
||||||
|
/** Ringe mit vielen Porträts laden verzögert — nur sichtbare zuerst. */
|
||||||
|
loading?: 'lazy' | 'eager'
|
||||||
|
/** Zusätzlicher Rahmen, etwa zur Hervorhebung des Kernteams. */
|
||||||
|
ringColor?: string
|
||||||
|
ringWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialsOf(name: string): string {
|
function initialsOf(name: string): string {
|
||||||
return name.trim().slice(0, 2).toUpperCase()
|
return name.trim().slice(0, 2).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function resolveSize(size: AgentAvatarSize): number {
|
||||||
* Es gibt keine Porträtbilder für digitale Mitarbeiter — und erfundene
|
return typeof size === 'number' ? size : NAMED_SIZES[size]
|
||||||
* Gesichter wären in einer Kundendemo irreführend. Stattdessen Initialen auf
|
}
|
||||||
* einer je Mitarbeiter festen Farbe.
|
|
||||||
*/
|
export const AgentAvatar = memo(function AgentAvatar({
|
||||||
export const AgentAvatar = memo(function AgentAvatar({ agent, size = 'medium', showStatus = false }: Props) {
|
agent,
|
||||||
const px = SIZES[size]
|
size = 'medium',
|
||||||
const isActive = agent.status === AgentStatus.ACTIVE
|
status,
|
||||||
const statusLabel = AGENT_STATUS_LABELS[agent.status] ?? agent.status
|
showStatus = false,
|
||||||
const badgePx = size === 'large' ? 20 : 14
|
tone,
|
||||||
|
loading = 'lazy',
|
||||||
|
ringColor,
|
||||||
|
ringWidth = 2,
|
||||||
|
}: Props) {
|
||||||
|
const px = resolveSize(size)
|
||||||
|
const photo = AGENT_PHOTOS[agent.id]
|
||||||
|
const label = `${agent.name}, ${agent.role}`
|
||||||
|
const isActive = status === Status.ACTIVE
|
||||||
|
const statusLabel = status ? (AGENT_STATUS_LABELS[status] ?? status) : ''
|
||||||
|
const badgePx = px >= 64 ? 20 : 14
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}>
|
<Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}>
|
||||||
{/* `alt` reicht MUI ausschliesslich an den img-Slot weiter, und ohne `src`
|
{/* Der zugängliche Name hängt an genau einem Element, nie an zweien:
|
||||||
rendert Avatar gar kein <img> — die Beschriftung landete damit nirgends
|
mit Porträt am erzeugten <img> über `alt`, ohne Porträt am Container
|
||||||
im DOM. Der zugängliche Name muss deshalb direkt auf das Element. */}
|
ü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
|
<Avatar
|
||||||
role="img"
|
src={photo}
|
||||||
aria-label={`${agent.name}, ${agent.role}`}
|
alt={photo ? label : undefined}
|
||||||
|
slotProps={{ img: { loading } }}
|
||||||
|
{...(photo ? {} : { role: 'img', 'aria-label': label })}
|
||||||
sx={{
|
sx={{
|
||||||
width: px,
|
width: px,
|
||||||
height: px,
|
height: px,
|
||||||
bgcolor: AVATAR_TONE_COLOR[agent.avatarTone],
|
bgcolor: tone ? AVATAR_TONE_COLOR[tone] : DS_BORDER.strong,
|
||||||
color: DS_TEXT.inverted,
|
color: DS_TEXT.inverted,
|
||||||
fontSize: px * 0.38,
|
fontSize: px * 0.38,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
...(ringColor
|
||||||
|
? { border: `${ringWidth}px solid ${ringColor}`, boxSizing: 'border-box' }
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Fällt das Bild aus, zeigt MUI wieder die Initialen statt eines Lochs. */}
|
||||||
{initialsOf(agent.name)}
|
{initialsOf(agent.name)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
{showStatus && (
|
{showStatus && status && (
|
||||||
<Tooltip title={statusLabel} arrow>
|
<Tooltip title={statusLabel} arrow>
|
||||||
<Box
|
<Box
|
||||||
aria-label={statusLabel}
|
aria-label={statusLabel}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import { Box, Tooltip, Typography } from '@mui/material'
|
||||||
|
import { agentDirectory } from '../../mock-data/agentDirectory'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 5
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
agentIds: string[]
|
||||||
|
size?: number
|
||||||
|
/** Text vor der Gruppe, z. B. «Genutzt durch». */
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Überlappende Porträtgruppe (§2.3, §7.1).
|
||||||
|
*
|
||||||
|
* Ersetzt die frühere Namensliste: wer ein System nutzt, ist als Gesicht
|
||||||
|
* schneller erfasst als als Aufzählung. Ab dem sechsten Eintrag steht ein
|
||||||
|
* Zähler, sonst franst die Gruppe in schmalen Karten aus.
|
||||||
|
*
|
||||||
|
* Die Namen bleiben über Tooltip und `aria-label` erreichbar — die Information
|
||||||
|
* darf nicht ausschliesslich im Bild stecken.
|
||||||
|
*/
|
||||||
|
export const AgentAvatarGroup = memo(function AgentAvatarGroup({
|
||||||
|
agentIds,
|
||||||
|
size = 24,
|
||||||
|
label,
|
||||||
|
}: Props) {
|
||||||
|
const { visible, overflow, allNames } = useMemo(() => {
|
||||||
|
const entries = agentIds
|
||||||
|
.map(id => agentDirectory.find(a => a.id === id))
|
||||||
|
.filter((a): a is NonNullable<typeof a> => !!a)
|
||||||
|
return {
|
||||||
|
visible: entries.slice(0, MAX_VISIBLE),
|
||||||
|
overflow: entries.slice(MAX_VISIBLE),
|
||||||
|
allNames: entries.map(a => a.name).join(', '),
|
||||||
|
}
|
||||||
|
}, [agentIds])
|
||||||
|
|
||||||
|
if (visible.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
Noch keinem Mitarbeitenden zugewiesen
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
|
{label && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, flexShrink: 0 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
role="list"
|
||||||
|
aria-label={allNames}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', pl: 0.5 }}
|
||||||
|
>
|
||||||
|
{visible.map((agent) => (
|
||||||
|
<Tooltip key={agent.id} title={`${agent.name} · ${agent.role}`} arrow>
|
||||||
|
<Box
|
||||||
|
role="listitem"
|
||||||
|
sx={{
|
||||||
|
ml: -0.5,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `2px solid ${DS_BG.surface}`,
|
||||||
|
display: 'flex',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
'&:hover': { transform: 'translateY(-2px)', zIndex: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentAvatar agent={agent} size={size} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<Tooltip title={overflow.map(a => a.name).join(', ')} arrow>
|
||||||
|
<Box
|
||||||
|
role="listitem"
|
||||||
|
sx={{
|
||||||
|
ml: -0.5,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `2px solid ${DS_BG.surface}`,
|
||||||
|
bgcolor: DS_BORDER.muted,
|
||||||
|
color: DS_TEXT.secondary,
|
||||||
|
fontSize: size * 0.36,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{overflow.length}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { memo } from 'react'
|
|
||||||
import { Box, Button, Typography } from '@mui/material'
|
|
||||||
import { ArrowRight } from 'lucide-react'
|
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
|
||||||
import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges'
|
|
||||||
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
|
|
||||||
import { formatTeamRelative } from '../../lib/teamClock'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
agent: TeamAgent
|
|
||||||
onOpen: (agentId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Agentenkarte im Kernteam-Raster (§6.2).
|
|
||||||
*
|
|
||||||
* Der Aktivstatus steht doppelt: als Haken am Avatar und als Textbadge. Farbe
|
|
||||||
* allein darf einen Zustand nie tragen (§2.3, §18).
|
|
||||||
*/
|
|
||||||
export const AgentCard = memo(function AgentCard({ agent, onOpen }: Props) {
|
|
||||||
const headline = agent.metrics.find(m => m.id === agent.headlineMetricId) ?? agent.metrics[0]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="article"
|
|
||||||
sx={{
|
|
||||||
border: `1px solid ${DS_BORDER.default}`,
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: DS_BG.surface,
|
|
||||||
boxShadow: DS_SHADOW.card,
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1.25,
|
|
||||||
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
|
|
||||||
'&:hover': { borderColor: DS_TEXT.brand, boxShadow: DS_SHADOW.panel },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
|
||||||
<AgentAvatar agent={agent} size="medium" showStatus />
|
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary, lineHeight: 1.3 }}>
|
|
||||||
{agent.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
|
||||||
{agent.role}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
|
||||||
Personalnummer {agent.personnelNumber}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, flex: 1 }}>
|
|
||||||
{agent.shortDescription}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
|
||||||
<AgentStatusBadge status={agent.status} />
|
|
||||||
<AgentAutonomyBadge autonomy={agent.autonomyLevel} note={agent.autonomyNote} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{headline && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
gap: 0.75,
|
|
||||||
pt: 1,
|
|
||||||
borderTop: `1px solid ${DS_BORDER.muted}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', color: DS_TEXT.primary }}>
|
|
||||||
{headline.value}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
|
||||||
{headline.label}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
|
||||||
{agent.lastRun ? `Zuletzt aktiv ${formatTeamRelative(agent.lastRun)}` : 'Noch nicht gelaufen'}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
endIcon={<ArrowRight size={14} />}
|
|
||||||
onClick={() => onOpen(agent.id)}
|
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
Personalblatt öffnen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,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 type { TeamAgent } from '../../domain/teamAgent'
|
||||||
import { AgentStatus } from '../../domain/teamAgent'
|
import { AgentStatus } from '../../domain/teamAgent'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges'
|
import { AgentStatusBadge } from './AgentBadges'
|
||||||
import { ConfirmDialog } from '../ui'
|
import { ConfirmDialog } from '../ui'
|
||||||
import { useSetAgentActive } from '../../hooks/useTeamAgents'
|
import { useSetAgentActive } from '../../hooks/useTeamAgents'
|
||||||
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
@@ -14,6 +14,22 @@ interface Props {
|
|||||||
agent: TeamAgent
|
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 }) {
|
function MetaField({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -48,7 +64,7 @@ export function AgentDossierHeader({ agent }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 2, bgcolor: DS_BG.surface, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 2, bgcolor: DS_BG.surface, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
||||||
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
<AgentAvatar agent={agent} size="large" showStatus />
|
<AgentAvatar agent={agent} size="large" status={agent.status} tone={agent.avatarTone} showStatus />
|
||||||
|
|
||||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||||
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.25rem', color: DS_TEXT.primary, lineHeight: 1.25 }}>
|
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.25rem', color: DS_TEXT.primary, lineHeight: 1.25 }}>
|
||||||
@@ -65,8 +81,33 @@ export function AgentDossierHeader({ agent }: Props) {
|
|||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
|
||||||
<AgentStatusBadge status={agent.status} />
|
<AgentStatusBadge status={agent.status} />
|
||||||
<AgentAutonomyBadge autonomy={agent.autonomyLevel} note={agent.autonomyNote} />
|
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -96,7 +137,6 @@ export function AgentDossierHeader({ agent }: Props) {
|
|||||||
>
|
>
|
||||||
<MetaField label="Personalnummer" value={agent.personnelNumber} />
|
<MetaField label="Personalnummer" value={agent.personnelNumber} />
|
||||||
<MetaField label="Abteilung" value={agent.department} />
|
<MetaField label="Abteilung" value={agent.department} />
|
||||||
<MetaField label="Autonomiegrad" value={agent.autonomyNote} />
|
|
||||||
<MetaField
|
<MetaField
|
||||||
label="Letzter Lauf"
|
label="Letzter Lauf"
|
||||||
value={agent.lastRun ? formatTeamDateTime(agent.lastRun) : 'Noch nicht gelaufen'}
|
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 },
|
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: -2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AgentAvatar agent={agent} size="small" showStatus />
|
<AgentAvatar agent={agent} size="small" status={agent.status} tone={agent.avatarTone} showStatus />
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
<Typography sx={{ fontWeight: selected ? 700 : 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
<Typography sx={{ fontWeight: selected ? 700 : 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
|
|||||||
@@ -0,0 +1,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 { AlertTriangle, Building2, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Info, Radio, Search, UserRound, XCircle } from 'lucide-react'
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
import type { AgentProtocolEntry } from '../../domain/agentProtocol'
|
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 { AgentPeriod } from '../../domain/agentFilters'
|
||||||
import type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider'
|
import type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider'
|
||||||
import { useAgentProtocol } from '../../hooks/useAgentProtocol'
|
import { useAgentProtocol } from '../../hooks/useAgentProtocol'
|
||||||
@@ -145,7 +145,6 @@ const ProtocolEntryRow = memo(function ProtocolEntryRow({ entry, expanded, isLas
|
|||||||
|
|
||||||
export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
|
export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
|
||||||
const [period, setPeriod] = useState<AgentPeriod>(AgentPeriod.ALL)
|
const [period, setPeriod] = useState<AgentPeriod>(AgentPeriod.ALL)
|
||||||
const [eventType, setEventType] = useState<AgentProtocolEventType | 'ALL'>('ALL')
|
|
||||||
const [status, setStatus] = useState<AgentProtocolStatus | 'ALL'>('ALL')
|
const [status, setStatus] = useState<AgentProtocolStatus | 'ALL'>('ALL')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [onlyApprovals, setOnlyApprovals] = useState(false)
|
const [onlyApprovals, setOnlyApprovals] = useState(false)
|
||||||
@@ -154,11 +153,10 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
|
|||||||
const filters = useMemo<AgentProtocolFilters>(() => ({
|
const filters = useMemo<AgentProtocolFilters>(() => ({
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
period,
|
period,
|
||||||
eventType: eventType === 'ALL' ? undefined : eventType,
|
|
||||||
status: status === 'ALL' ? undefined : status,
|
status: status === 'ALL' ? undefined : status,
|
||||||
onlyApprovals: onlyApprovals || undefined,
|
onlyApprovals: onlyApprovals || undefined,
|
||||||
search: search.trim() || 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 { data: entries = [], isLoading, isError, refetch } = useAgentProtocol(filters)
|
||||||
const lastId = entries.length > 0 ? entries[entries.length - 1].id : null
|
const lastId = entries.length > 0 ? entries[entries.length - 1].id : null
|
||||||
@@ -167,7 +165,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
|
|||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setPeriod(AgentPeriod.ALL)
|
setPeriod(AgentPeriod.ALL)
|
||||||
setEventType('ALL')
|
|
||||||
setStatus('ALL')
|
setStatus('ALL')
|
||||||
setSearch('')
|
setSearch('')
|
||||||
setOnlyApprovals(false)
|
setOnlyApprovals(false)
|
||||||
@@ -190,10 +187,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
|
|||||||
labels={AGENT_PERIOD_LABELS} allLabel={AGENT_PERIOD_LABELS.ALL}
|
labels={AGENT_PERIOD_LABELS} allLabel={AGENT_PERIOD_LABELS.ALL}
|
||||||
onChange={(v) => setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)}
|
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
|
<FilterSelect
|
||||||
label="Status" value={status} options={Object.values(AgentProtocolStatus)}
|
label="Status" value={status} options={Object.values(AgentProtocolStatus)}
|
||||||
labels={AGENT_PROTOCOL_STATUS_LABELS} allLabel="Alle Status" onChange={setStatus}
|
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 { memo, useCallback, useMemo, useState } from 'react'
|
||||||
import { Box, Button, Switch, Typography } from '@mui/material'
|
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 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 { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
import { GenericBadge } from '../shared/GenericBadge'
|
import { GenericBadge } from '../shared/GenericBadge'
|
||||||
import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
|
import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
|
||||||
@@ -30,17 +29,6 @@ interface TaskRowProps {
|
|||||||
* jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2).
|
* jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2).
|
||||||
*/
|
*/
|
||||||
const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
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(
|
const handleChange = useCallback(
|
||||||
(_event: unknown, checked: boolean) => onToggle(task.id, checked),
|
(_event: unknown, checked: boolean) => onToggle(task.id, checked),
|
||||||
[onToggle, task.id],
|
[onToggle, task.id],
|
||||||
@@ -51,9 +39,9 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
|||||||
component="li"
|
component="li"
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 1.5,
|
gap: 1,
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
p: 1.75,
|
p: 1.25,
|
||||||
border: `1px solid ${DS_BORDER.default}`,
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: task.enabled ? DS_BG.surface : DS_BG.subtle,
|
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) */}
|
{/* 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
|
<Switch
|
||||||
checked={task.enabled}
|
checked={task.enabled}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -83,32 +71,30 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
<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}
|
{task.title}
|
||||||
</Typography>
|
</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}
|
{task.description}
|
||||||
</Typography>
|
</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 && (
|
{task.requiresApproval && (
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 0.75 }}>
|
||||||
<GenericBadge
|
<GenericBadge
|
||||||
label="Freigabe erforderlich"
|
label="Freigabe erforderlich"
|
||||||
semanticVariant="confidenceMedium"
|
semanticVariant="confidenceMedium"
|
||||||
@@ -224,7 +210,22 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
|
|||||||
Für {agent.name} sind noch keine Aufgaben hinterlegt.
|
Für {agent.name} sind noch keine Aufgaben hinterlegt.
|
||||||
</Typography>
|
</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) => (
|
{draft.map((task) => (
|
||||||
<TaskRow key={task.id} task={task} busy={busy} onToggle={handleToggle} />
|
<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'
|
* Property On — Karte einer zentralen Verbindung (§13).
|
||||||
import { Plug, PlugZap, Unplug, Users } from 'lucide-react'
|
*
|
||||||
import type { AgentConnection } from '../../domain/agentConnection'
|
* Verbunden oder nicht verbunden ist ein Zweizustand, also trägt ihn ein Regler
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
* und keine Knopfreihe: einschalten öffnet den Verbindungsassistenten,
|
||||||
import { AgentConnectionStatus } from '../../domain/teamAgent'
|
* ausschalten löst die Trennung aus, die der Aufrufer weiterhin bestätigen
|
||||||
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
|
* lässt. «Verbindung testen» bleibt ein Knopf — das ist keine Zustandsänderung.
|
||||||
|
*
|
||||||
|
* Markenlogos gibt es im Projekt nicht und werden auch nicht nachgezeichnet.
|
||||||
|
* Stattdessen trägt jede Kategorie ein neutrales, einheitliches Sinnbild.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo, useCallback } from 'react'
|
||||||
|
import { Box, Button, Switch, Typography } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
AGENT_CONNECTION_CATEGORY_LABELS,
|
Calendar,
|
||||||
AGENT_ERP_VENDOR_LABELS,
|
Contact,
|
||||||
} from '../../lib/constants'
|
Database,
|
||||||
|
FolderOpen,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
Phone,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { AgentConnection } from '../../domain/agentConnection'
|
||||||
|
import { AgentConnectionCategory } from '../../domain/agentConnection'
|
||||||
|
import { AgentConnectionStatus } from '../../domain/teamAgent'
|
||||||
|
import { AgentAvatarGroup } from './AgentAvatarGroup'
|
||||||
|
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
|
||||||
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
|
||||||
import { formatTeamDateTime } from '../../lib/teamClock'
|
import { formatTeamRelative } from '../../lib/teamClock'
|
||||||
|
|
||||||
|
const CATEGORY_ICON: Record<AgentConnectionCategory, LucideIcon> = {
|
||||||
|
[AgentConnectionCategory.EMAIL_M365]: Mail,
|
||||||
|
[AgentConnectionCategory.WHATSAPP]: MessageCircle,
|
||||||
|
[AgentConnectionCategory.TEAMS]: Users,
|
||||||
|
[AgentConnectionCategory.CALENDAR]: Calendar,
|
||||||
|
[AgentConnectionCategory.DOCUMENT_STORE]: FolderOpen,
|
||||||
|
[AgentConnectionCategory.PHONE_VOICE]: Phone,
|
||||||
|
[AgentConnectionCategory.ERP]: Database,
|
||||||
|
[AgentConnectionCategory.CRM]: Contact,
|
||||||
|
[AgentConnectionCategory.PUBLIC_SOURCES]: Globe,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sinnbild der Kategorie in ruhiger Fläche. Auch von der Kurzliste auf der
|
||||||
|
* Teamübersicht genutzt, damit beide Ansichten dasselbe Zeichen führen.
|
||||||
|
*/
|
||||||
|
export const ConnectionCategoryIcon = memo(function ConnectionCategoryIcon({
|
||||||
|
category,
|
||||||
|
size = 28,
|
||||||
|
}: {
|
||||||
|
category: AgentConnectionCategory
|
||||||
|
size?: number
|
||||||
|
}) {
|
||||||
|
const Icon = CATEGORY_ICON[category]
|
||||||
|
const label = AGENT_CONNECTION_CATEGORY_LABELS[category] ?? category
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
sx={{
|
||||||
|
width: size, height: size, flexShrink: 0,
|
||||||
|
borderRadius: 1.5, bgcolor: DS_BG.subtle,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={Math.round(size * 0.54)} color={DS_TEXT.secondary} aria-hidden />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connection: AgentConnection
|
connection: AgentConnection
|
||||||
agents: TeamAgent[]
|
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onConfigure: (connectionId: string) => void
|
onConfigure: (connectionId: string) => void
|
||||||
onDisconnect: (connectionId: string) => void
|
onDisconnect: (connectionId: string) => void
|
||||||
@@ -23,7 +84,6 @@ interface Props {
|
|||||||
|
|
||||||
export const ConnectionCard = memo(function ConnectionCard({
|
export const ConnectionCard = memo(function ConnectionCard({
|
||||||
connection,
|
connection,
|
||||||
agents,
|
|
||||||
busy,
|
busy,
|
||||||
onConfigure,
|
onConfigure,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
@@ -31,10 +91,19 @@ export const ConnectionCard = memo(function ConnectionCard({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const isConnected = connection.status === AgentConnectionStatus.CONNECTED
|
const isConnected = connection.status === AgentConnectionStatus.CONNECTED
|
||||||
const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP
|
const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP
|
||||||
const users = connection.usedByAgentIds
|
|
||||||
.map(id => agents.find(a => a.id === id)?.name ?? id)
|
const handleToggle = useCallback(() => {
|
||||||
.join(', ')
|
if (isConnected) onDisconnect(connection.id)
|
||||||
const grantedPermissions = connection.permissions.filter(p => p.granted)
|
else onConfigure(connection.id)
|
||||||
|
}, [isConnected, onDisconnect, onConfigure, connection.id])
|
||||||
|
|
||||||
|
const handleDetails = useCallback(() => onConfigure(connection.id), [onConfigure, connection.id])
|
||||||
|
const handleTest = useCallback(() => onTest(connection.id), [onTest, connection.id])
|
||||||
|
|
||||||
|
// Neun Regler auf einer Seite: der Name muss im Bedienhilfe-Text mitkommen.
|
||||||
|
const switchLabel = isRoadmap
|
||||||
|
? `${connection.name}: geplant, noch nicht verfügbar`
|
||||||
|
: `${connection.name} ${isConnected ? 'trennen' : 'verbinden'}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -44,103 +113,87 @@ export const ConnectionCard = memo(function ConnectionCard({
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: DS_BG.surface,
|
bgcolor: DS_BG.surface,
|
||||||
boxShadow: DS_SHADOW.card,
|
boxShadow: DS_SHADOW.card,
|
||||||
p: 2,
|
p: 1.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 1.25,
|
gap: 1,
|
||||||
|
transition: 'border-color 0.15s ease',
|
||||||
|
'&:hover': { borderColor: DS_BORDER.strong },
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<ConnectionCategoryIcon category={connection.category} />
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
|
||||||
{connection.name}
|
<Typography
|
||||||
</Typography>
|
title={connection.name}
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
sx={{
|
||||||
{AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.category}
|
flex: 1, minWidth: 0, color: DS_TEXT.primary,
|
||||||
{connection.vendor ? ` · ${AGENT_ERP_VENDOR_LABELS[connection.vendor]}` : ''}
|
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>
|
</Typography>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isConnected}
|
||||||
|
disabled={busy || isRoadmap}
|
||||||
|
onChange={handleToggle}
|
||||||
|
slotProps={{ input: { 'aria-label': switchLabel } }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<AgentConnectionStatusBadge status={connection.status} />
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, flex: 1 }}>
|
<AgentAvatarGroup agentIds={connection.usedByAgentIds} label="Genutzt durch" size={20} />
|
||||||
{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>
|
|
||||||
|
|
||||||
|
<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 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
{connection.lastSyncAt
|
{connection.lastSyncAt
|
||||||
? `Letzte Synchronisation ${formatTeamDateTime(connection.lastSyncAt)}`
|
? `Synchronisiert ${formatTeamRelative(connection.lastSyncAt)}`
|
||||||
: 'Noch keine Synchronisation'}
|
: 'Noch keine Synchronisation'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{grantedPermissions.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
|
||||||
{grantedPermissions.map((permission) => (
|
|
||||||
<AgentAccessBadge key={permission.id} access={permission.access} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', pt: 0.5 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={isConnected ? 'outlined' : 'contained'}
|
|
||||||
disabled={busy || isRoadmap}
|
|
||||||
startIcon={isConnected ? <Plug size={14} /> : <PlugZap size={14} />}
|
|
||||||
onClick={() => onConfigure(connection.id)}
|
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{isConnected ? 'Konfigurieren' : 'Verbinden'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
<>
|
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onTest(connection.id)}
|
onClick={handleTest}
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
aria-label={`${connection.name}: Verbindung testen`}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 600, minWidth: 0, px: 0.75 }}
|
||||||
>
|
>
|
||||||
Verbindung testen
|
Verbindung testen
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
startIcon={<Unplug size={14} />}
|
onClick={handleDetails}
|
||||||
onClick={() => onDisconnect(connection.id)}
|
aria-label={`${connection.name}: Details und Berechtigungen`}
|
||||||
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
|
sx={{ textTransform: 'none', fontWeight: 600, minWidth: 0, px: 0.75 }}
|
||||||
>
|
>
|
||||||
Trennen
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{isRoadmap && (
|
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, alignSelf: 'center' }}>
|
|
||||||
Diese Anbindung ist geplant.
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,66 +1,95 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import type { AgentConnection } from '../../domain/agentConnection'
|
import type { AgentConnection } from '../../domain/agentConnection'
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
import { AgentConnectionStatusBadge } from './AgentBadges'
|
import { AgentConnectionStatusBadge } from './AgentBadges'
|
||||||
|
import { AgentAvatarGroup } from './AgentAvatarGroup'
|
||||||
|
import { ConnectionCategoryIcon } from './ConnectionCard'
|
||||||
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
|
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
|
||||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
import { formatTeamRelative } from '../../lib/teamClock'
|
import { formatTeamRelative } from '../../lib/teamClock'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connections: AgentConnection[]
|
connections: AgentConnection[]
|
||||||
agents: TeamAgent[]
|
/**
|
||||||
|
* Wird nicht mehr benötigt: die Porträtgruppe löst die Namen selbst aus dem
|
||||||
|
* Verzeichnis auf. Bleibt optional erhalten, damit bestehende Aufrufer
|
||||||
|
* unverändert weiterlaufen.
|
||||||
|
*/
|
||||||
|
agents?: TeamAgent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kompakte Darstellung der Verbindungen auf der Teamübersicht (§6.6).
|
* Kompakte Darstellung der Verbindungen auf der Teamübersicht (§6.6).
|
||||||
* Dieselben fachlichen Inhalte wie auf der vollständigen Seite «Kanäle & Systeme»,
|
* Dasselbe Kartenmuster wie auf «Kanäle & Systeme» — Sinnbild, Name, Zustand,
|
||||||
* nur ohne Aktionen — verbunden wird dort, nicht hier.
|
* Porträtgruppe —, nur ohne Aktionen: verbunden wird dort, nicht hier.
|
||||||
*/
|
*/
|
||||||
export function ConnectionSummaryList({ connections, agents }: Props) {
|
export function ConnectionSummaryList({ connections }: Props) {
|
||||||
const nameById = new Map(agents.map(a => [a.id, a.name]))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)', xl: 'repeat(3, 1fr)' },
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
md: 'repeat(2, minmax(0, 1fr))',
|
||||||
|
xl: 'repeat(4, minmax(0, 1fr))',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{connections.map((connection) => {
|
{connections.map((connection) => (
|
||||||
const users = connection.usedByAgentIds.map(id => nameById.get(id) ?? id)
|
<ConnectionSummaryCard key={connection.id} connection={connection} />
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
</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)}
|
onChange={() => toggle(agent.id)}
|
||||||
slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }}
|
slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }}
|
||||||
/>
|
/>
|
||||||
<AgentAvatar agent={agent} size="small" />
|
<AgentAvatar agent={agent} size="small" tone={agent.avatarTone} />
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
|
|||||||
@@ -0,0 +1,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 type { ReactNode } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
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'
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -57,12 +55,6 @@ export function TeamPageHeader({ title, description, actions, tabs }: Props) {
|
|||||||
</Typography>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{actions && (
|
{actions && (
|
||||||
|
|||||||
@@ -1,53 +1,39 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Button } from '@mui/material'
|
import { Box, Button } from '@mui/material'
|
||||||
import { Check, CheckCheck, FileSearch, MessageSquareReply, Pencil, Scale, X } from 'lucide-react'
|
import { Building2, Check, FileSearch, X } from 'lucide-react'
|
||||||
import type { AgentWorkItem, AgentEditableField } from '../../domain/agentWorkItem'
|
import type { AgentWorkItem } from '../../domain/agentWorkItem'
|
||||||
import { AgentWorkItemAction } from '../../domain/agentWorkItem'
|
|
||||||
import { ConfirmDialog } from '../ui'
|
import { ConfirmDialog } from '../ui'
|
||||||
import { RejectWorkItemDialog, EditWorkItemDialog, AnswerQueryDialog } from './WorkItemActionDialogs'
|
import { RejectWorkItemDialog } from './WorkItemActionDialogs'
|
||||||
import {
|
import { useApproveWorkItem, useRejectWorkItem } from '../../hooks/useAgentWorkItems'
|
||||||
useApproveWorkItem,
|
|
||||||
useRejectWorkItem,
|
|
||||||
useSaveWorkItemEdit,
|
|
||||||
useAnswerWorkItemQuery,
|
|
||||||
useMarkWorkItemDone,
|
|
||||||
} from '../../hooks/useAgentWorkItems'
|
|
||||||
import { DS_BG, DS_BORDER } from '../../lib/ds'
|
import { DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
|
|
||||||
type OpenDialog = 'none' | 'approve' | 'decide' | 'reject' | 'edit' | 'answer'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: AgentWorkItem
|
item: AgentWorkItem
|
||||||
/** Springt zu den Fundstellen — «Quelle öffnen» ohne echtes Zielsystem. */
|
/** Springt im Drawer zum Abschnitt «Quellen» — öffnet keine neue Seite. */
|
||||||
onOpenSource: () => void
|
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
|
* Pendent: Freigeben, Zurückweisen, Quellen anzeigen, Inserat anzeigen.
|
||||||
* sind irreversibel und dürfen nicht versehentlich ausgelöst werden (§17.3).
|
* Erledigt: nur noch der Weg zum Objekt — entschieden ist entschieden.
|
||||||
* 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.
|
* Die Freigabe läuft über einen Bestätigungsdialog: sie ist irreversibel und
|
||||||
|
* darf nicht versehentlich ausgelöst werden.
|
||||||
*/
|
*/
|
||||||
export function WorkItemActions({ item, onOpenSource }: Props) {
|
export function WorkItemActions({ item, onShowSources, onOpenProperty }: Props) {
|
||||||
const [dialog, setDialog] = useState<OpenDialog>('none')
|
const [confirmApprove, setConfirmApprove] = useState(false)
|
||||||
|
const [confirmReject, setConfirmReject] = useState(false)
|
||||||
|
|
||||||
const approve = useApproveWorkItem()
|
const approve = useApproveWorkItem()
|
||||||
const reject = useRejectWorkItem()
|
const reject = useRejectWorkItem()
|
||||||
const saveEdit = useSaveWorkItemEdit()
|
const busy = approve.isPending || reject.isPending
|
||||||
const answer = useAnswerWorkItemQuery()
|
|
||||||
const markDone = useMarkWorkItemDone()
|
|
||||||
|
|
||||||
const busy =
|
const hasSources = item.sourceReferences.length > 0
|
||||||
approve.isPending || reject.isPending || saveEdit.isPending || answer.isPending || markDone.isPending
|
const hasProperty = !!item.objectId
|
||||||
|
|
||||||
const can = (action: string) => item.availableActions.includes(action as never)
|
|
||||||
const close = () => setDialog('none')
|
|
||||||
|
|
||||||
const runApprove = () => {
|
|
||||||
approve.mutate(item.id, { onSuccess: close })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -62,158 +48,88 @@ export function WorkItemActions({ item, onOpenSource }: Props) {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{can(AgentWorkItemAction.ONE_CLICK_CONFIRM) && (
|
{item.requiresDecision ? (
|
||||||
<Button
|
<>
|
||||||
variant="contained"
|
<Button
|
||||||
size="small"
|
variant="contained"
|
||||||
disabled={busy}
|
size="small"
|
||||||
startIcon={<CheckCheck size={15} />}
|
disabled={busy}
|
||||||
onClick={runApprove}
|
startIcon={<Check size={15} />}
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
onClick={() => setConfirmApprove(true)}
|
||||||
>
|
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||||
Mit einem Klick bestätigen
|
>
|
||||||
</Button>
|
Freigeben
|
||||||
)}
|
</Button>
|
||||||
|
|
||||||
{can(AgentWorkItemAction.APPROVE) && (
|
<Button
|
||||||
<Button
|
variant="outlined"
|
||||||
variant="contained"
|
color="error"
|
||||||
size="small"
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
startIcon={<Check size={15} />}
|
startIcon={<X size={15} />}
|
||||||
onClick={() => setDialog('approve')}
|
onClick={() => setConfirmReject(true)}
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
Freigeben
|
Zurückweisen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
{can(AgentWorkItemAction.DECIDE) && (
|
{hasSources && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
size="small"
|
||||||
size="small"
|
startIcon={<FileSearch size={15} />}
|
||||||
disabled={busy}
|
onClick={onShowSources}
|
||||||
startIcon={<Scale size={15} />}
|
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||||
onClick={() => setDialog('decide')}
|
>
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
Quellen anzeigen
|
||||||
>
|
</Button>
|
||||||
Entscheidung treffen
|
)}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{can(AgentWorkItemAction.ANSWER_QUERY) && (
|
{hasProperty && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
size="small"
|
||||||
size="small"
|
startIcon={<Building2 size={15} />}
|
||||||
disabled={busy}
|
onClick={onOpenProperty}
|
||||||
startIcon={<MessageSquareReply size={15} />}
|
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
|
||||||
onClick={() => setDialog('answer')}
|
>
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
Inserat anzeigen
|
||||||
>
|
</Button>
|
||||||
Rückfrage beantworten
|
)}
|
||||||
</Button>
|
</>
|
||||||
)}
|
) : (
|
||||||
|
hasProperty && (
|
||||||
{can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && (
|
<Button
|
||||||
<Button
|
variant="outlined"
|
||||||
variant="outlined"
|
size="small"
|
||||||
size="small"
|
startIcon={<Building2 size={15} />}
|
||||||
disabled={busy}
|
onClick={onOpenProperty}
|
||||||
startIcon={<Pencil size={15} />}
|
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||||
onClick={() => setDialog('edit')}
|
>
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
Objekt anzeigen
|
||||||
>
|
</Button>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={dialog === 'approve'}
|
open={confirmApprove}
|
||||||
title="Vorgang freigeben"
|
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.`}
|
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"
|
confirmLabel="Freigeben"
|
||||||
onConfirm={runApprove}
|
onConfirm={() => {
|
||||||
onCancel={close}
|
approve.mutate(item.id, { onSuccess: () => setConfirmApprove(false) })
|
||||||
/>
|
}}
|
||||||
|
onCancel={() => setConfirmApprove(false)}
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RejectWorkItemDialog
|
<RejectWorkItemDialog
|
||||||
open={dialog === 'reject'}
|
open={confirmReject}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
busy={reject.isPending}
|
busy={reject.isPending}
|
||||||
onCancel={close}
|
onCancel={() => setConfirmReject(false)}
|
||||||
onConfirm={(reason) => reject.mutate({ id: item.id, reason }, { onSuccess: close })}
|
onConfirm={(reason) =>
|
||||||
/>
|
reject.mutate({ id: item.id, reason }, { onSuccess: () => setConfirmReject(false) })
|
||||||
|
|
||||||
<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 })
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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 { memo, useMemo } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Tooltip, Typography } from '@mui/material'
|
||||||
import { AlertTriangle, Building2, Radio } from 'lucide-react'
|
|
||||||
import type { AgentWorkItem } from '../../domain/agentWorkItem'
|
import type { AgentWorkItem } from '../../domain/agentWorkItem'
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
|
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
|
||||||
import {
|
import { formatTeamDateTime } from '../../lib/teamClock'
|
||||||
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'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: AgentWorkItem
|
item: AgentWorkItem
|
||||||
@@ -19,18 +13,30 @@ interface Props {
|
|||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AVATAR_PX = 44
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Eintrag im Bearbeitungsverlauf.
|
* Ein Eintrag im Bearbeitungsverlauf.
|
||||||
*
|
*
|
||||||
* `React.memo` ist hier nicht optional: die Liste rendert bis zu 14 Karten und
|
* Bewusst auf das Nötigste reduziert: wer, was, wann. Priorität, Status,
|
||||||
* der Drawer verändert bei jeder Auswahl den Zustand der Seite — ohne Memo
|
* Vorgangstyp und Kanal standen auf praktisch jeder Karte und trugen deshalb
|
||||||
* würde jede Auswahl die gesamte Liste neu zeichnen (CLAUDE.md §10.2).
|
* 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) {
|
export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, onSelect }: Props) {
|
||||||
const kindLabel = AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind
|
|
||||||
const channelLabel = AGENT_CHANNEL_LABELS[item.sourceChannel] ?? item.sourceChannel
|
|
||||||
const timestamp = item.completedAt ?? item.createdAt
|
const 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="article"
|
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}`,
|
border: `1px solid ${selected ? DS_TEXT.brand : DS_BORDER.default}`,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: DS_BG.surface,
|
bgcolor: DS_BG.surface,
|
||||||
p: 1.75,
|
p: 2,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card,
|
boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card,
|
||||||
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
|
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
|
||||||
'&:hover': { borderColor: DS_TEXT.brand },
|
'&:hover': { borderColor: DS_BORDER.strong },
|
||||||
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 },
|
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 },
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||||
{agent && <AgentAvatar agent={agent} size="medium" />}
|
{agent && (
|
||||||
|
<Tooltip arrow title={`${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 }}>
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
{/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */}
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
|
||||||
{agent?.name ?? 'Unbekannt'}
|
{agent?.name ?? 'Unbekannt'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -73,67 +87,24 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.5 }}>
|
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.75 }}>
|
||||||
{item.title}
|
{title}
|
||||||
</Typography>
|
</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}
|
{item.summary}
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useRef } from 'react'
|
||||||
import { Box, Divider, Drawer, IconButton, Typography } from '@mui/material'
|
import { Box, Button, Drawer, IconButton, Typography } from '@mui/material'
|
||||||
import { HelpCircle, X } from 'lucide-react'
|
import { ArrowUpRight, HelpCircle, X } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
import { useTeamStore } from '../../stores/teamStore'
|
import { useTeamStore } from '../../stores/teamStore'
|
||||||
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
||||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
|
|
||||||
import { WorkItemActions } from './WorkItemActions'
|
import { WorkItemActions } from './WorkItemActions'
|
||||||
import {
|
import { DetailFieldList, DetailSectionTitle, MessageThread, SourceReferenceList } from './WorkItemDetailSections'
|
||||||
DetailFieldList,
|
|
||||||
DetailSectionTitle,
|
|
||||||
MessageThread,
|
|
||||||
ProcessingStepList,
|
|
||||||
SourceReferenceList,
|
|
||||||
} from './WorkItemDetailSections'
|
|
||||||
import { PanelLoadingState } from '../ui'
|
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 { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
import { formatTeamDateTime } from '../../lib/teamClock'
|
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
|
* Bewusst schmal gehalten: Priorität, Status, Vorgangstyp, Verarbeitungsschritte
|
||||||
* nach jeder Entscheidung sofort wieder in ihr stehen — ein Seitenwechsel
|
* und Aktionshistorie sind entfallen. Sie beschrieben die Maschine, nicht den
|
||||||
* würde bei jedem Vorgang den Kontext zerstören.
|
* 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() {
|
export function WorkItemDetailDrawer() {
|
||||||
const selectedId = useTeamStore(s => s.selectedWorkItemId)
|
const selectedId = useTeamStore(s => s.selectedWorkItemId)
|
||||||
@@ -32,6 +31,7 @@ export function WorkItemDetailDrawer() {
|
|||||||
const { data: item, isLoading } = useAgentWorkItem(selectedId)
|
const { data: item, isLoading } = useAgentWorkItem(selectedId)
|
||||||
const { data: agents = [] } = useTeamAgents()
|
const { data: agents = [] } = useTeamAgents()
|
||||||
const sourcesRef = useRef<HTMLDivElement | null>(null)
|
const sourcesRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const agent = agents.find(a => a.id === item?.agentId)
|
const agent = agents.find(a => a.id === item?.agentId)
|
||||||
const close = useCallback(() => setSelectedId(null), [setSelectedId])
|
const close = useCallback(() => setSelectedId(null), [setSelectedId])
|
||||||
@@ -40,14 +40,30 @@ export function WorkItemDetailDrawer() {
|
|||||||
sourcesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
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 (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="right"
|
anchor="right"
|
||||||
open={!!selectedId}
|
open={!!selectedId}
|
||||||
onClose={close}
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -58,13 +74,13 @@ export function WorkItemDetailDrawer() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{agent && <AgentAvatar agent={agent} size="medium" />}
|
{agent && <AgentAvatar agent={agent} size="medium" tone={agent.avatarTone} />}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
|
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, lineHeight: 1.35 }}>
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="small" onClick={close} aria-label="Detailansicht schliessen">
|
<IconButton size="small" onClick={close} aria-label="Detailansicht schliessen">
|
||||||
@@ -72,32 +88,17 @@ export function WorkItemDetailDrawer() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Inhalt */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
|
||||||
{isLoading && <PanelLoadingState />}
|
{isLoading && <PanelLoadingState />}
|
||||||
|
|
||||||
{item && (
|
{item && (
|
||||||
<Box sx={{ p: 2, display: 'grid', gap: 2.5 }}>
|
<Box sx={{ p: 2, display: 'grid', gap: 2.5 }}>
|
||||||
{/* Metazeile */}
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
{formatTeamDateTime(item.completedAt ?? item.createdAt)}
|
||||||
<AgentPriorityBadge priority={item.priority} />
|
</Typography>
|
||||||
<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="body2" sx={{ color: DS_TEXT.secondary }}>{item.summary}</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) && (
|
{item.requiresDecision && (item.escalationReason || item.decisionQuestion) && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -110,7 +111,7 @@ export function WorkItemDetailDrawer() {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||||
<HelpCircle size={15} color={DS_TEXT.warning} aria-hidden />
|
<HelpCircle size={15} color={DS_TEXT.warning} aria-hidden />
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', color: DS_TEXT.warningDark }}>
|
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', color: DS_TEXT.warningDark }}>
|
||||||
Warum ist ein Mensch erforderlich?
|
Hierzu brauche ich Ihre Entscheidung
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{item.escalationReason && (
|
{item.escalationReason && (
|
||||||
@@ -133,30 +134,25 @@ export function WorkItemDetailDrawer() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.messageThread && item.messageThread.length > 0 && (
|
{/* Nur die auslösende Nachricht — der Rest liegt im Anfragencenter. */}
|
||||||
|
{firstMessage.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<DetailSectionTitle>Nachrichtenverlauf</DetailSectionTitle>
|
<DetailSectionTitle>Auslösende Nachricht</DetailSectionTitle>
|
||||||
<MessageThread messages={item.messageThread} />
|
<MessageThread messages={firstMessage} />
|
||||||
</Box>
|
<Button
|
||||||
)}
|
size="small"
|
||||||
|
endIcon={<ArrowUpRight size={14} />}
|
||||||
{item.inputs.length > 0 && (
|
onClick={openCorrespondence}
|
||||||
<Box>
|
sx={{ textTransform: 'none', fontWeight: 600, mt: 0.5, px: 0 }}
|
||||||
<DetailSectionTitle>Eingabedaten</DetailSectionTitle>
|
>
|
||||||
<DetailFieldList fields={item.inputs} />
|
Gesamte Korrespondenz im Anfragencenter anzeigen
|
||||||
</Box>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
{item.processingSteps.length > 0 && (
|
|
||||||
<Box>
|
|
||||||
<DetailSectionTitle>Verarbeitungsschritte</DetailSectionTitle>
|
|
||||||
<ProcessingStepList steps={item.processingSteps} />
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.sourceReferences.length > 0 && (
|
{item.sourceReferences.length > 0 && (
|
||||||
<Box ref={sourcesRef} sx={{ scrollMarginTop: 8 }}>
|
<Box ref={sourcesRef} sx={{ scrollMarginTop: 8 }}>
|
||||||
<DetailSectionTitle>Fundstellen</DetailSectionTitle>
|
<DetailSectionTitle>Quellen</DetailSectionTitle>
|
||||||
<SourceReferenceList references={item.sourceReferences} />
|
<SourceReferenceList references={item.sourceReferences} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -167,39 +163,16 @@ export function WorkItemDetailDrawer() {
|
|||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
||||||
</Box>
|
</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>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{item && item.availableActions.length > 0 && (
|
{item && (
|
||||||
<WorkItemActions item={item} onOpenSource={scrollToSources} />
|
<WorkItemActions
|
||||||
|
item={item}
|
||||||
|
onShowSources={scrollToSources}
|
||||||
|
onOpenProperty={openProperty}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { Box, Button, InputAdornment, MenuItem, TextField } from '@mui/material'
|
import { Box, Button, InputAdornment, MenuItem, TextField } from '@mui/material'
|
||||||
import { RotateCcw, Search } from 'lucide-react'
|
import { RotateCcw, Search } from 'lucide-react'
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
import { AgentChannelType } from '../../domain/teamAgent'
|
import { AgentDomainArea, AgentWorkItemStatus } from '../../domain/agentWorkItem'
|
||||||
import { AgentDomainArea, AgentWorkItemKind, AgentWorkItemPriority, AgentWorkItemStatus } from '../../domain/agentWorkItem'
|
|
||||||
import { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters'
|
import { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters'
|
||||||
import { useTeamStore } from '../../stores/teamStore'
|
import { useTeamStore } from '../../stores/teamStore'
|
||||||
import {
|
import {
|
||||||
AGENT_PERIOD_LABELS,
|
AGENT_PERIOD_LABELS,
|
||||||
AGENT_DOMAIN_AREA_LABELS,
|
AGENT_DOMAIN_AREA_LABELS,
|
||||||
AGENT_WORK_ITEM_KIND_LABELS,
|
|
||||||
AGENT_PRIORITY_LABELS,
|
|
||||||
AGENT_WORK_ITEM_STATUS_LABELS,
|
AGENT_WORK_ITEM_STATUS_LABELS,
|
||||||
AGENT_CHANNEL_LABELS,
|
|
||||||
} from '../../lib/constants'
|
} from '../../lib/constants'
|
||||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
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 period = useTeamStore(s => s.historyPeriod)
|
||||||
const agentId = useTeamStore(s => s.historyAgentId)
|
const agentId = useTeamStore(s => s.historyAgentId)
|
||||||
const area = useTeamStore(s => s.historyArea)
|
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 status = useTeamStore(s => s.historyStatus)
|
||||||
const search = useTeamStore(s => s.historySearch)
|
const search = useTeamStore(s => s.historySearch)
|
||||||
const sort = useTeamStore(s => s.historySort)
|
const sort = useTeamStore(s => s.historySort)
|
||||||
@@ -86,9 +79,6 @@ export function WorkItemFilterBar({ agents }: Props) {
|
|||||||
const setPeriod = useTeamStore(s => s.setHistoryPeriod)
|
const setPeriod = useTeamStore(s => s.setHistoryPeriod)
|
||||||
const setAgentId = useTeamStore(s => s.setHistoryAgentId)
|
const setAgentId = useTeamStore(s => s.setHistoryAgentId)
|
||||||
const setArea = useTeamStore(s => s.setHistoryArea)
|
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 setStatus = useTeamStore(s => s.setHistoryStatus)
|
||||||
const setSearch = useTeamStore(s => s.setHistorySearch)
|
const setSearch = useTeamStore(s => s.setHistorySearch)
|
||||||
const setSort = useTeamStore(s => s.setHistorySort)
|
const setSort = useTeamStore(s => s.setHistorySort)
|
||||||
@@ -98,9 +88,6 @@ export function WorkItemFilterBar({ agents }: Props) {
|
|||||||
period !== AgentPeriod.ALL ||
|
period !== AgentPeriod.ALL ||
|
||||||
agentId !== 'ALL' ||
|
agentId !== 'ALL' ||
|
||||||
area !== 'ALL' ||
|
area !== 'ALL' ||
|
||||||
kind !== 'ALL' ||
|
|
||||||
priority !== 'ALL' ||
|
|
||||||
channel !== 'ALL' ||
|
|
||||||
status !== 'ALL' ||
|
status !== 'ALL' ||
|
||||||
search.trim() !== '' ||
|
search.trim() !== '' ||
|
||||||
sort !== AgentWorkItemSort.NEWEST
|
sort !== AgentWorkItemSort.NEWEST
|
||||||
@@ -110,22 +97,21 @@ export function WorkItemFilterBar({ agents }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'grid',
|
||||||
gap: 1.25,
|
gap: 1.25,
|
||||||
flexWrap: 'wrap',
|
|
||||||
alignItems: 'center',
|
|
||||||
px: 3,
|
px: 3,
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
borderBottom: `1px solid ${DS_BORDER.default}`,
|
borderBottom: `1px solid ${DS_BORDER.default}`,
|
||||||
bgcolor: DS_BG.page,
|
bgcolor: DS_BG.page,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Zeile 1: nur die Suche — sie ist der häufigste Einstieg. */}
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Vorgänge durchsuchen"
|
placeholder="Vorgänge durchsuchen"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
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
|
<FilterSelect
|
||||||
label="Zeitraum"
|
label="Zeitraum"
|
||||||
value={period === AgentPeriod.ALL ? 'ALL' : period}
|
value={period === AgentPeriod.ALL ? 'ALL' : period}
|
||||||
@@ -167,36 +155,6 @@ export function WorkItemFilterBar({ agents }: Props) {
|
|||||||
onChange={setArea}
|
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
|
<FilterSelect
|
||||||
label="Status"
|
label="Status"
|
||||||
value={status}
|
value={status}
|
||||||
@@ -230,6 +188,7 @@ export function WorkItemFilterBar({ agents }: Props) {
|
|||||||
Filter zurücksetzen
|
Filter zurücksetzen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ interface Props {
|
|||||||
emptyDescription: string
|
emptyDescription: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstand zwischen zwei Vorgängen. Bewusst grosszügig: die Karten tragen kaum
|
||||||
|
* noch Farbe, die Trennung übernimmt der Weissraum.
|
||||||
|
*/
|
||||||
|
const LIST_GAP = 2.5
|
||||||
|
|
||||||
export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
|
export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
|
||||||
const { data: items = [], isLoading, isError, refetch } = useAgentWorkItems(filters)
|
const { data: items = [], isLoading, isError, refetch } = useAgentWorkItems(filters)
|
||||||
const { data: agents = [] } = useTeamAgents()
|
const { data: agents = [] } = useTeamAgents()
|
||||||
@@ -25,7 +31,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
|
<Box sx={{ p: 3, display: 'grid', gap: LIST_GAP }}>
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
@@ -42,7 +48,7 @@ export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
|
<Box sx={{ p: 3, display: 'grid', gap: LIST_GAP }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<WorkItemCard
|
<WorkItemCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -1,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 ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('WorkItemCard — Inhalt', () => {
|
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 item = workItem(COMPLETED_ID)
|
||||||
const agent = agentOf(item)
|
const agent = agentOf(item)
|
||||||
renderCard(item)
|
renderCard(item)
|
||||||
|
|
||||||
const card = screen.getByRole('button')
|
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(item.summary)).toBeInTheDocument()
|
||||||
expect(within(card).getByText(agent.name)).toBeInTheDocument()
|
expect(within(card).getByText(agent.name)).toBeInTheDocument()
|
||||||
expect(within(card).getByText(agent.role)).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 item = workItem(COMPLETED_ID)
|
||||||
const objectId = required(item.objectId, `Vorgang «${item.id}» hat keine Objekt-ID`)
|
const objectId = required(item.objectId, `Vorgang «${item.id}» hat keine Objekt-ID`)
|
||||||
renderCard(item)
|
renderCard(item)
|
||||||
|
|
||||||
const card = screen.getByRole('button')
|
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', () => {
|
it('zeigt ohne Objektbezug keine Objekt-ID', () => {
|
||||||
@@ -95,14 +97,14 @@ describe('WorkItemCard — Inhalt', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('WorkItemCard — Grund der Rückfrage', () => {
|
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 item = workItem(PENDING_ID)
|
||||||
const reason = required(item.escalationReason, `Vorgang «${item.id}» hat keinen Rückfragegrund`)
|
const reason = required(item.escalationReason, `Vorgang «${item.id}» hat keinen Rückfragegrund`)
|
||||||
expect(item.requiresDecision).toBe(true)
|
expect(item.requiresDecision).toBe(true)
|
||||||
renderCard(item)
|
renderCard(item)
|
||||||
|
|
||||||
const card = screen.getByRole('button')
|
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', () => {
|
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 { AgentAvatar } from './AgentAvatar'
|
||||||
export type { AgentAvatarSize } from './AgentAvatar'
|
export type { AgentAvatarSize } from './AgentAvatar'
|
||||||
|
export { AgentAvatarGroup } from './AgentAvatarGroup'
|
||||||
|
export { AGENT_PHOTOS } from './agentPhotos'
|
||||||
|
export {
|
||||||
|
RING_SPECS,
|
||||||
|
RING_REFERENCE_PX,
|
||||||
|
ringSpecFor,
|
||||||
|
layoutArc,
|
||||||
|
WHEEL_ARC,
|
||||||
|
WHEEL_ACTIVE_ANGLE_DEG,
|
||||||
|
} from './agentRingConfig'
|
||||||
|
export type { RingSpec, RingNodePosition, ArcOptions } from './agentRingConfig'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AgentStatusBadge,
|
AgentStatusBadge,
|
||||||
@@ -16,14 +27,32 @@ export {
|
|||||||
export { TeamPageHeader } from './TeamPageHeader'
|
export { TeamPageHeader } from './TeamPageHeader'
|
||||||
export { TeamSectionHeader } from './TeamSectionHeader'
|
export { TeamSectionHeader } from './TeamSectionHeader'
|
||||||
export { TeamKpiSection } from './TeamKpiSection'
|
export { TeamKpiSection } from './TeamKpiSection'
|
||||||
export { AgentCard } from './AgentCard'
|
|
||||||
|
// Kreisdarstellung der Belegschaft — ersetzt das frühere Kernteam-Kartenraster
|
||||||
|
export { TeamLevelTabs } from './TeamLevelTabs'
|
||||||
|
export { TeamOverviewRing } from './TeamOverviewRing'
|
||||||
|
export { AgentRing } from './AgentRing'
|
||||||
|
export { AgentRingNode } from './AgentRingNode'
|
||||||
|
export { AgentPreviewPopover } from './AgentPreviewPopover'
|
||||||
export { ConnectionSummaryList } from './ConnectionSummaryList'
|
export { ConnectionSummaryList } from './ConnectionSummaryList'
|
||||||
|
|
||||||
// Personalverwaltung
|
// Personalverwaltung
|
||||||
|
export {
|
||||||
|
AgentDossier,
|
||||||
|
DOSSIER_TABS,
|
||||||
|
DEFAULT_DOSSIER_SEGMENT,
|
||||||
|
isDossierSegment,
|
||||||
|
resolveDossierSegment,
|
||||||
|
} from './AgentDossier'
|
||||||
|
export type { DossierSegment } from './AgentDossier'
|
||||||
|
export { AgentDescriptionTab } from './AgentDescriptionTab'
|
||||||
|
export { RotatableAgentWheel } from './RotatableAgentWheel'
|
||||||
export { AgentListPanel } from './AgentListPanel'
|
export { AgentListPanel } from './AgentListPanel'
|
||||||
export { AgentDossierHeader } from './AgentDossierHeader'
|
export { AgentDossierHeader } from './AgentDossierHeader'
|
||||||
export { AgentInfoBox } from './AgentInfoBox'
|
export { AgentInfoBox } from './AgentInfoBox'
|
||||||
export { AgentTasksTab } from './AgentTasksTab'
|
export { AgentTasksTab } from './AgentTasksTab'
|
||||||
|
export { AgentMetricsTab } from './AgentMetricsTab'
|
||||||
|
export { AgentConnectionsTab } from './AgentConnectionsTab'
|
||||||
export { AgentChannelsTab } from './AgentChannelsTab'
|
export { AgentChannelsTab } from './AgentChannelsTab'
|
||||||
export { AgentSystemsTab } from './AgentSystemsTab'
|
export { AgentSystemsTab } from './AgentSystemsTab'
|
||||||
export { AgentSettingsTab } from './AgentSettingsTab'
|
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',
|
ALL: 'Gesamter Zeitraum',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personalstufen der digitalen Belegschaft.
|
||||||
|
*
|
||||||
|
* Wortlaut unverändert aus dem Konzeptdokument übernommen — die Reiter sind
|
||||||
|
* kumulativ zu lesen: «+ Zentrale Dienste» meint Kernteam plus Zentrale Dienste.
|
||||||
|
*/
|
||||||
|
export const AGENT_LEVEL_LABELS: Record<string, string> = {
|
||||||
|
CORE: 'Kernteam',
|
||||||
|
CENTRAL_SERVICES: 'Zentrale Dienste',
|
||||||
|
COMMERCIAL: 'GB Kommerzielle Immobilien',
|
||||||
|
RESIDENTIAL: 'GB Wohnen',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschriftung der Filterreiter — ebenfalls Wortlaut des Konzepts. */
|
||||||
|
export const AGENT_LEVEL_TAB_LABELS: Record<string, string> = {
|
||||||
|
CORE: 'Kernteam',
|
||||||
|
CENTRAL_SERVICES: '+ Zentrale Dienste',
|
||||||
|
COMMERCIAL: '+ GB Kommerziell',
|
||||||
|
RESIDENTIAL: 'Alle 36',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_DEPARTMENT_LABELS: Record<string, string> = {
|
||||||
|
CENTRAL_SERVICES: 'Zentrale Dienste',
|
||||||
|
COMMERCIAL: 'GB Kommerzielle Immobilien',
|
||||||
|
RESIDENTIAL: 'GB Wohnen',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aufbaustand aus dem Konzept — nicht der Betriebszustand aktiv/pausiert. */
|
||||||
|
export const AGENT_ROLLOUT_LABELS: Record<string, string> = {
|
||||||
|
PROTOTYPE: 'Prototyp läuft',
|
||||||
|
IN_PROGRESS: 'Im Aufbau',
|
||||||
|
PLANNED: 'Geplant',
|
||||||
|
}
|
||||||
|
|
||||||
/** Simulierte Ladezeit für Demo-Aktionen (§19.1: 300–800 ms). */
|
/** Simulierte Ladezeit für Demo-Aktionen (§19.1: 300–800 ms). */
|
||||||
export const AGENT_SIM_DELAY_MS = 420
|
export const AGENT_SIM_DELAY_MS = 420
|
||||||
export const AGENT_SIM_DELAY_SLOW_MS = 760
|
export const AGENT_SIM_DELAY_SLOW_MS = 760
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Verzeichnis aller digitalen Mitarbeitenden.
|
||||||
|
*
|
||||||
|
* Quelle ist das Konzeptdokument «Agenten_Bewirtschaftung_v2.html» (Stand
|
||||||
|
* 24.07.2026): Namen, Funktionen, Personalstufen, Bereiche und Rollout-Status
|
||||||
|
* sind daraus unverändert übernommen, ebenso die Porträts.
|
||||||
|
*
|
||||||
|
* Abgrenzung zu `mockTeamAgents`: dieses Verzeichnis trägt nur die Stammdaten,
|
||||||
|
* die für die Kreisdarstellung nötig sind. Ein vollständiges Personaldossier —
|
||||||
|
* Aufgaben, Kanäle, Systemzugänge, Einstellungen, Protokoll — besitzen
|
||||||
|
* ausschliesslich die sieben Kernteammitglieder in `src/mock-data/agents/`.
|
||||||
|
* Für die übrigen 29 werden bewusst keine Inhalte erfunden.
|
||||||
|
*
|
||||||
|
* ACHTUNG: generiert aus dem Konzept. Nicht von Hand pflegen — bei einer neuen
|
||||||
|
* Konzeptfassung neu erzeugen, sonst driften Verzeichnis und Quelle auseinander.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentDirectoryEntry } from '../domain/agentDirectory'
|
||||||
|
import { AgentLevel, AgentDepartment, AgentRollout } from '../domain/agentDirectory'
|
||||||
|
|
||||||
|
export const agentDirectory: AgentDirectoryEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'bruno',
|
||||||
|
name: 'Bruno',
|
||||||
|
role: 'Besichtigungs-Briefing',
|
||||||
|
email: 'bruno@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi',
|
||||||
|
name: 'Ferdi',
|
||||||
|
role: 'Fristen-Wächter',
|
||||||
|
email: 'ferdi@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PROTOTYPE,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea',
|
||||||
|
name: 'Lea',
|
||||||
|
role: 'Anfragen-Manager',
|
||||||
|
email: 'lea@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PROTOTYPE,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia',
|
||||||
|
name: 'Livia',
|
||||||
|
role: 'Lage-Analyst',
|
||||||
|
email: 'livia@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora',
|
||||||
|
name: 'Nora',
|
||||||
|
role: 'Markt-Scout',
|
||||||
|
email: 'nora@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PROTOTYPE,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto',
|
||||||
|
name: 'Reto',
|
||||||
|
role: 'Recap-Agent',
|
||||||
|
email: 'reto@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina',
|
||||||
|
name: 'Sina',
|
||||||
|
role: 'Vertragsauskunft',
|
||||||
|
email: 'sina@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ada',
|
||||||
|
name: 'Ada',
|
||||||
|
role: 'Datenqualitäts-Wächterin',
|
||||||
|
email: 'ada@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PROTOTYPE,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'carla',
|
||||||
|
name: 'Carla',
|
||||||
|
role: 'Bestands-Radar',
|
||||||
|
email: 'carla@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'diego',
|
||||||
|
name: 'Diego',
|
||||||
|
role: 'Decision-Brief-Ersteller',
|
||||||
|
email: 'diego@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PROTOTYPE,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dora',
|
||||||
|
name: 'Dora',
|
||||||
|
role: 'Dossier-Erstellerin',
|
||||||
|
email: 'dora@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emil',
|
||||||
|
name: 'Emil',
|
||||||
|
role: 'Unterlagen-Versand',
|
||||||
|
email: 'emil@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filip',
|
||||||
|
name: 'Filip',
|
||||||
|
role: 'Ablage-Agent',
|
||||||
|
email: 'filip@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kira',
|
||||||
|
name: 'Kira',
|
||||||
|
role: 'Konkurrenz-Beobachterin',
|
||||||
|
email: 'kira@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lars',
|
||||||
|
name: 'Lars',
|
||||||
|
role: 'Archiv-Digitalisierer',
|
||||||
|
email: 'lars@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mia',
|
||||||
|
name: 'Mia',
|
||||||
|
role: 'Mietpreis-Benchmarkerin',
|
||||||
|
email: 'mia@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'paul',
|
||||||
|
name: 'Paul',
|
||||||
|
role: 'Pipeline-Führer',
|
||||||
|
email: 'paul@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pia',
|
||||||
|
name: 'Pia',
|
||||||
|
role: 'Mietantritt-Koordinatorin',
|
||||||
|
email: 'pia@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rea',
|
||||||
|
name: 'Rea',
|
||||||
|
role: 'Referenz-Einholerin',
|
||||||
|
email: 'rea@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rico',
|
||||||
|
name: 'Rico',
|
||||||
|
role: 'Eigentümer-Reporter',
|
||||||
|
email: 'rico@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'uli',
|
||||||
|
name: 'Uli',
|
||||||
|
role: 'Übergabe-Assistent',
|
||||||
|
email: 'uli@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vela',
|
||||||
|
name: 'Vela',
|
||||||
|
role: 'Cockpit-Agentin',
|
||||||
|
email: 'vela@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vera',
|
||||||
|
name: 'Vera',
|
||||||
|
role: 'Inserat-Texterin',
|
||||||
|
email: 'vera@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vinz',
|
||||||
|
name: 'Vinz',
|
||||||
|
role: 'Vertragsentwurf-Assistent',
|
||||||
|
email: 'vinz@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vito',
|
||||||
|
name: 'Vito',
|
||||||
|
role: 'Objekt-Erfasser',
|
||||||
|
email: 'vito@property-on.ch',
|
||||||
|
level: AgentLevel.CENTRAL_SERVICES,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'beat',
|
||||||
|
name: 'Beat',
|
||||||
|
role: 'Zahlungs-Frühwarner',
|
||||||
|
email: 'beat@property-on.ch',
|
||||||
|
level: AgentLevel.COMMERCIAL,
|
||||||
|
department: AgentDepartment.COMMERCIAL,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabio',
|
||||||
|
name: 'Fabio',
|
||||||
|
role: 'Fit-out-Rechner',
|
||||||
|
email: 'fabio@property-on.ch',
|
||||||
|
level: AgentLevel.COMMERCIAL,
|
||||||
|
department: AgentDepartment.COMMERCIAL,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nadia',
|
||||||
|
name: 'Nadia',
|
||||||
|
role: 'Umsatzmiete-Abrechnerin',
|
||||||
|
email: 'nadia@property-on.ch',
|
||||||
|
level: AgentLevel.COMMERCIAL,
|
||||||
|
department: AgentDepartment.COMMERCIAL,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oskar',
|
||||||
|
name: 'Oskar',
|
||||||
|
role: 'Offerten-Schreiber',
|
||||||
|
email: 'oskar@property-on.ch',
|
||||||
|
level: AgentLevel.COMMERCIAL,
|
||||||
|
department: AgentDepartment.COMMERCIAL,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'otto',
|
||||||
|
name: 'Otto',
|
||||||
|
role: 'Options-Vorbereiter',
|
||||||
|
email: 'otto@property-on.ch',
|
||||||
|
level: AgentLevel.COMMERCIAL,
|
||||||
|
department: AgentDepartment.COMMERCIAL,
|
||||||
|
rollout: AgentRollout.PLANNED,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sven',
|
||||||
|
name: 'Sven',
|
||||||
|
role: 'Mieter-Check',
|
||||||
|
email: 'sven@property-on.ch',
|
||||||
|
level: AgentLevel.COMMERCIAL,
|
||||||
|
department: AgentDepartment.COMMERCIAL,
|
||||||
|
rollout: AgentRollout.PROTOTYPE,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tino',
|
||||||
|
name: 'Tino',
|
||||||
|
role: 'Mietzins-Rechner',
|
||||||
|
email: 'tino@property-on.ch',
|
||||||
|
level: AgentLevel.COMMERCIAL,
|
||||||
|
department: AgentDepartment.COMMERCIAL,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gian',
|
||||||
|
name: 'Gian',
|
||||||
|
role: 'Privat-Check',
|
||||||
|
email: 'gian@property-on.ch',
|
||||||
|
level: AgentLevel.RESIDENTIAL,
|
||||||
|
department: AgentDepartment.RESIDENTIAL,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ida',
|
||||||
|
name: 'Ida',
|
||||||
|
role: 'Nebenkosten-Assistentin',
|
||||||
|
email: 'ida@property-on.ch',
|
||||||
|
level: AgentLevel.RESIDENTIAL,
|
||||||
|
department: AgentDepartment.RESIDENTIAL,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jana',
|
||||||
|
name: 'Jana',
|
||||||
|
role: 'Wechsel-Koordinatorin Wohnen',
|
||||||
|
email: 'jana@property-on.ch',
|
||||||
|
level: AgentLevel.RESIDENTIAL,
|
||||||
|
department: AgentDepartment.RESIDENTIAL,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zeno',
|
||||||
|
name: 'Zeno',
|
||||||
|
role: 'Referenzzins-Spezialist',
|
||||||
|
email: 'zeno@property-on.ch',
|
||||||
|
level: AgentLevel.RESIDENTIAL,
|
||||||
|
department: AgentDepartment.RESIDENTIAL,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Nur die sieben Kernteammitglieder — sie besitzen ein vollständiges Dossier. */
|
||||||
|
export const coreAgentIds: string[] = agentDirectory.filter(a => a.isCore).map(a => a.id)
|
||||||
@@ -72,7 +72,6 @@ export default function Bearbeitungsverlauf() {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<TeamPageHeader
|
<TeamPageHeader
|
||||||
title="Bearbeitungsverlauf"
|
title="Bearbeitungsverlauf"
|
||||||
description="Was die digitalen Mitarbeiter selbständig erledigt haben — und wo sie eine Entscheidung der Bewirtschaftung brauchen."
|
|
||||||
tabs={
|
tabs={
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
|
|||||||
@@ -1,19 +1,53 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
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 {
|
import {
|
||||||
TeamPageHeader,
|
TeamPageHeader,
|
||||||
ConnectionCard,
|
ConnectionCard,
|
||||||
ConnectionWizard,
|
ConnectionWizard,
|
||||||
} from '../../components/team'
|
} from '../../components/team'
|
||||||
import { CardSkeleton, ConfirmDialog, ErrorState } from '../../components/ui'
|
import { CardSkeleton, ConfirmDialog, ErrorState } from '../../components/ui'
|
||||||
import { AgentConnectionStatus } from '../../domain/teamAgent'
|
|
||||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||||
import {
|
import {
|
||||||
useAgentConnections,
|
useAgentConnections,
|
||||||
useDisconnectAgentConnection,
|
useDisconnectAgentConnection,
|
||||||
useTestAgentConnection,
|
useTestAgentConnection,
|
||||||
} from '../../hooks/useAgentConnections'
|
} from '../../hooks/useAgentConnections'
|
||||||
import { DS_TEXT } from '../../lib/ds'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subreiter «Kanäle & Systeme» (§13).
|
* Subreiter «Kanäle & Systeme» (§13).
|
||||||
@@ -42,46 +76,52 @@ export default function KanaeleSysteme() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<TeamPageHeader
|
<TeamPageHeader title="Kanäle & Systeme" />
|
||||||
title="Kanäle & Systeme"
|
|
||||||
description="Alle zentralen Verbindungen der Organisation. Die digitalen Mitarbeiter arbeiten in den Werkzeugen, die Sie ohnehin einsetzen."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState message="Die Verbindungen konnten nicht geladen werden." onRetry={() => refetch()} />
|
<ErrorState message="Die Verbindungen konnten nicht geladen werden." onRetry={() => refetch()} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}>
|
{isLoading ? (
|
||||||
{connections.filter(c => c.status === AgentConnectionStatus.CONNECTED).length} von {connections.length}{' '}
|
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: GRID_COLUMNS }}>
|
||||||
Verbindungen sind eingerichtet.
|
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => <CardSkeleton key={i} />)}
|
||||||
</Typography>
|
</Box>
|
||||||
|
) : (
|
||||||
<Box
|
SECTIONS.map((section) => {
|
||||||
sx={{
|
const items = connections.filter(c => section.categories.includes(c.category))
|
||||||
display: 'grid',
|
if (items.length === 0) return null
|
||||||
gap: 1.5,
|
return (
|
||||||
gridTemplateColumns: {
|
<Box component="section" key={section.title} sx={{ mb: 4 }}>
|
||||||
xs: '1fr',
|
<Typography
|
||||||
md: 'repeat(2, 1fr)',
|
component="h2"
|
||||||
xl: 'repeat(3, 1fr)',
|
sx={{
|
||||||
},
|
fontWeight: 700,
|
||||||
}}
|
fontSize: '0.75rem',
|
||||||
>
|
letterSpacing: '0.04em',
|
||||||
{isLoading
|
textTransform: 'uppercase',
|
||||||
? [0, 1, 2, 3, 4, 5].map((i) => <CardSkeleton key={i} />)
|
color: DS_TEXT.muted,
|
||||||
: connections.map((connection) => (
|
mb: 1.5,
|
||||||
<ConnectionCard
|
}}
|
||||||
key={connection.id}
|
>
|
||||||
connection={connection}
|
{section.title}
|
||||||
agents={agents}
|
</Typography>
|
||||||
busy={busy}
|
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: GRID_COLUMNS }}>
|
||||||
onConfigure={handleConfigure}
|
{items.map((connection) => (
|
||||||
onDisconnect={handleDisconnect}
|
<ConnectionCard
|
||||||
onTest={handleTest}
|
key={connection.id}
|
||||||
/>
|
connection={connection}
|
||||||
))}
|
busy={busy}
|
||||||
</Box>
|
onConfigure={handleConfigure}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
onTest={handleTest}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,132 +1,146 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams, useSearchParams } from 'react-router'
|
||||||
import { Box, Tab, Tabs, useMediaQuery, useTheme } from '@mui/material'
|
import { Box, ToggleButton, ToggleButtonGroup, useMediaQuery, useTheme } from '@mui/material'
|
||||||
|
import { CircleDot, Rows3 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
TeamPageHeader,
|
TeamPageHeader,
|
||||||
AgentListPanel,
|
AgentListPanel,
|
||||||
AgentDossierHeader,
|
AgentDossier,
|
||||||
AgentInfoBox,
|
RotatableAgentWheel,
|
||||||
AgentTasksTab,
|
resolveDossierSegment,
|
||||||
AgentChannelsTab,
|
|
||||||
AgentSystemsTab,
|
|
||||||
AgentSettingsTab,
|
|
||||||
AgentProtocolTab,
|
|
||||||
} from '../../components/team'
|
} from '../../components/team'
|
||||||
|
import type { DossierSegment } from '../../components/team'
|
||||||
import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui'
|
import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui'
|
||||||
|
import { agentDirectory } from '../../mock-data/agentDirectory'
|
||||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||||
import { ROUTES } from '../../lib/constants'
|
import { ROUTES } from '../../lib/constants'
|
||||||
import { DS_BG, DS_BORDER } from '../../lib/ds'
|
import { DS_BG } from '../../lib/ds'
|
||||||
|
|
||||||
/** URL-Segment je Dossier-Reiter — Deep-Links müssen erhalten bleiben (§7.4). */
|
const VIEW_PARAM = 'view'
|
||||||
const DOSSIER_TABS = [
|
const VIEW = { DOSSIER: 'dossier', ORG: 'organigramm' } as const
|
||||||
{ segment: 'aufgaben', label: 'Aufgaben' },
|
type PersonnelView = typeof VIEW[keyof typeof VIEW]
|
||||||
{ segment: 'kanaele', label: 'Kanäle' },
|
|
||||||
{ segment: 'systeme', label: 'Systeme' },
|
|
||||||
{ segment: 'einstellungen', label: 'Einstellungen' },
|
|
||||||
{ segment: 'protokoll', label: 'Protokoll' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type DossierSegment = typeof DOSSIER_TABS[number]['segment']
|
function isView(value: string | null): value is PersonnelView {
|
||||||
|
return value === VIEW.DOSSIER || value === VIEW.ORG
|
||||||
const DEFAULT_SEGMENT: DossierSegment = 'aufgaben'
|
|
||||||
|
|
||||||
function isSegment(value: string | undefined): value is DossierSegment {
|
|
||||||
return DOSSIER_TABS.some(t => t.segment === value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nur die sieben Kernteammitglieder — sie besitzen ein Personaldossier. */
|
||||||
|
const coreEntries = agentDirectory.filter(a => a.isCore)
|
||||||
|
|
||||||
export default function Personalverwaltung() {
|
export default function Personalverwaltung() {
|
||||||
const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>()
|
const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
|
||||||
|
|
||||||
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
|
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
|
||||||
const activeSegment: DossierSegment = isSegment(tab) ? tab : DEFAULT_SEGMENT
|
|
||||||
const selectedAgent = agents.find(a => a.id === agentId) ?? null
|
const view: PersonnelView = isView(searchParams.get(VIEW_PARAM))
|
||||||
|
? (searchParams.get(VIEW_PARAM) as PersonnelView)
|
||||||
|
: VIEW.DOSSIER
|
||||||
|
const activeSegment: DossierSegment = 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
|
// Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres
|
||||||
// Dossier wäre für den Nutzer eine Sackgasse.
|
// Dossier wäre für den Nutzer eine Sackgasse.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId && agents.length > 0) {
|
if (!agentId && agents.length > 0) {
|
||||||
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}`, { replace: true })
|
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}?${VIEW_PARAM}=${view}`, { replace: true })
|
||||||
}
|
}
|
||||||
}, [agentId, agents, navigate])
|
}, [agentId, agents, navigate, view])
|
||||||
|
|
||||||
const selectAgent = useCallback(
|
const goTo = useCallback(
|
||||||
(id: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${id}/${activeSegment}`),
|
(nextAgentId: string, segment: DossierSegment) => {
|
||||||
[navigate, activeSegment],
|
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${nextAgentId}/${segment}?${VIEW_PARAM}=${view}`)
|
||||||
|
},
|
||||||
|
[navigate, view],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectTab = useCallback(
|
const selectAgent = useCallback(
|
||||||
|
(id: string) => goTo(id, activeSegment),
|
||||||
|
[goTo, activeSegment],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectSegment = useCallback(
|
||||||
(segment: DossierSegment) => {
|
(segment: DossierSegment) => {
|
||||||
if (agentId) navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}/${segment}`)
|
if (agentId) goTo(agentId, segment)
|
||||||
},
|
},
|
||||||
[navigate, agentId],
|
[goTo, agentId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const changeView = useCallback(
|
||||||
|
(next: PersonnelView | null) => {
|
||||||
|
if (!next) return
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
params.set(VIEW_PARAM, next)
|
||||||
|
setSearchParams(params, { replace: false })
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const dossier = selectedAgent && (
|
||||||
|
<AgentDossier
|
||||||
|
agent={selectedAgent}
|
||||||
|
activeSegment={activeSegment}
|
||||||
|
onSegmentChange={selectSegment}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<TeamPageHeader
|
<TeamPageHeader
|
||||||
title="Personalverwaltung"
|
title="Personalverwaltung"
|
||||||
description="Personaldossier jedes digitalen Mitarbeiters: Aufgaben, Kanäle, Systemzugänge, Einstellungen und Protokoll."
|
actions={
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={view}
|
||||||
|
onChange={(_, next: PersonnelView | null) => changeView(next)}
|
||||||
|
aria-label="Ansicht"
|
||||||
|
>
|
||||||
|
<ToggleButton value={VIEW.DOSSIER} sx={{ textTransform: 'none', gap: 0.75, px: 1.5 }}>
|
||||||
|
<Rows3 size={15} aria-hidden />
|
||||||
|
Dossieransicht
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value={VIEW.ORG} sx={{ textTransform: 'none', gap: 0.75, px: 1.5 }}>
|
||||||
|
<CircleDot size={15} aria-hidden />
|
||||||
|
Organigramm
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState message="Die Personalverwaltung konnte nicht geladen werden." onRetry={() => refetch()} />
|
<ErrorState message="Die Personalverwaltung konnte nicht geladen werden." onRetry={() => refetch()} />
|
||||||
|
) : isLoading ? (
|
||||||
|
<PanelLoadingState />
|
||||||
|
) : !selectedAgent ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Kein Mitarbeitender gewählt"
|
||||||
|
description="Wählen Sie einen digitalen Mitarbeitenden, um sein Personaldossier zu öffnen."
|
||||||
|
/>
|
||||||
|
) : view === VIEW.ORG ? (
|
||||||
|
/* Organigramm: Halbkreis oben, Dossier des aktiven Mitarbeitenden darunter */
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
|
||||||
|
<RotatableAgentWheel
|
||||||
|
agents={coreEntries}
|
||||||
|
selectedId={selectedAgent.id}
|
||||||
|
onSelect={selectAgent}
|
||||||
|
/>
|
||||||
|
{dossier}
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
/* Dossieransicht: Agentenliste links, Dossier rechts */
|
||||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: isCompact ? 'column' : 'row', overflow: 'hidden' }}>
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: isCompact ? 'column' : 'row', overflow: 'hidden' }}>
|
||||||
{/* Ebene 2: Agentenliste */}
|
|
||||||
<AgentListPanel
|
<AgentListPanel
|
||||||
agents={agents}
|
agents={agents}
|
||||||
selectedId={selectedAgent?.id ?? null}
|
selectedId={selectedAgent.id}
|
||||||
onSelect={selectAgent}
|
onSelect={selectAgent}
|
||||||
compact={isCompact}
|
compact={isCompact}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Ebene 3: Personaldossier */}
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page, minWidth: 0 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page, minWidth: 0 }}>
|
||||||
{isLoading && <PanelLoadingState />}
|
{dossier}
|
||||||
|
|
||||||
{!isLoading && !selectedAgent && (
|
|
||||||
<EmptyState
|
|
||||||
title="Kein Mitarbeiter gewählt"
|
|
||||||
description="Wählen Sie links einen digitalen Mitarbeiter, um sein Personaldossier zu öffnen."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedAgent && (
|
|
||||||
<>
|
|
||||||
<AgentDossierHeader agent={selectedAgent} />
|
|
||||||
<AgentInfoBox profile={selectedAgent.profile} />
|
|
||||||
|
|
||||||
<Box sx={{ px: 3, mt: 2, borderBottom: `1px solid ${DS_BORDER.default}` }}>
|
|
||||||
<Tabs
|
|
||||||
value={activeSegment}
|
|
||||||
onChange={(_, v: DossierSegment) => selectTab(v)}
|
|
||||||
variant="scrollable"
|
|
||||||
scrollButtons="auto"
|
|
||||||
allowScrollButtonsMobile
|
|
||||||
sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem' } }}
|
|
||||||
>
|
|
||||||
{DOSSIER_TABS.map((t) => (
|
|
||||||
<Tab key={t.segment} value={t.segment} label={t.label} />
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* `key` auf die Mitarbeiter-ID: beim Wechsel des Dossiers werden die
|
|
||||||
Reiter neu aufgebaut. Das ersetzt Zurücksetz-Effekte in jedem
|
|
||||||
einzelnen Reiter — sonst stünde der unbestätigte Entwurf des zuvor
|
|
||||||
gewählten Mitarbeiters in einem fremden Personalblatt. */}
|
|
||||||
<Box sx={{ px: 3, py: 2.5 }}>
|
|
||||||
{activeSegment === 'aufgaben' && <AgentTasksTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'kanaele' && <AgentChannelsTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'systeme' && <AgentSystemsTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'einstellungen' && <AgentSettingsTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
{activeSegment === 'protokoll' && <AgentProtocolTab key={selectedAgent.id} agent={selectedAgent} />}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,89 +1,58 @@
|
|||||||
import { useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { Box } from '@mui/material'
|
||||||
import { Box, Button } from '@mui/material'
|
|
||||||
import { RotateCcw } from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
TeamPageHeader,
|
TeamPageHeader,
|
||||||
TeamSectionHeader,
|
TeamSectionHeader,
|
||||||
TeamKpiSection,
|
TeamKpiSection,
|
||||||
AgentCard,
|
TeamLevelTabs,
|
||||||
|
TeamOverviewRing,
|
||||||
ConnectionSummaryList,
|
ConnectionSummaryList,
|
||||||
} from '../../components/team'
|
} from '../../components/team'
|
||||||
import { CardSkeleton, ErrorState } from '../../components/ui'
|
import { ErrorState } from '../../components/ui'
|
||||||
|
import type { AgentLevel } from '../../domain/agentDirectory'
|
||||||
|
import { AgentLevel as Level } from '../../domain/agentDirectory'
|
||||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||||
import { useAgentConnections } from '../../hooks/useAgentConnections'
|
import { useAgentConnections } from '../../hooks/useAgentConnections'
|
||||||
import { useResetTeamDemo } from '../../hooks/useTeamDemo'
|
|
||||||
import { ROUTES } from '../../lib/constants'
|
import { ROUTES } from '../../lib/constants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startseite des Property-On-Bereichs.
|
* Startseite des Property-On-Bereichs.
|
||||||
* Verbindliche Reihenfolge: Auswertung, Kernteam, Kanäle & Systeme (§4.1).
|
*
|
||||||
|
* Aufbau: die Belegschaft als Kreis direkt unter dem Seitentitel, darunter die
|
||||||
|
* Auswertung und die Verbindungen. Der Kreis steht bewusst zuoberst — er ist die
|
||||||
|
* Antwort auf die Frage, mit der ein Bewirtschafter die Seite öffnet: «wer
|
||||||
|
* arbeitet hier eigentlich für mich?»
|
||||||
|
*
|
||||||
|
* Kopfbereich bewusst ohne Beschreibungstext und ohne «Demo zurücksetzen»: der
|
||||||
|
* Platz gehört der Visualisierung.
|
||||||
*/
|
*/
|
||||||
export default function Teamuebersicht() {
|
export default function Teamuebersicht() {
|
||||||
const navigate = useNavigate()
|
const [level, setLevel] = useState<AgentLevel>(Level.CORE)
|
||||||
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
|
const { data: agents = [], isError, refetch } = useTeamAgents()
|
||||||
const { data: connections = [] } = useAgentConnections()
|
const { data: connections = [] } = useAgentConnections()
|
||||||
const resetDemo = useResetTeamDemo()
|
|
||||||
|
|
||||||
const openAgent = useCallback(
|
|
||||||
(agentId: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}`),
|
|
||||||
[navigate],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<TeamPageHeader
|
<TeamPageHeader title="Teamübersicht" />
|
||||||
title="Teamübersicht"
|
|
||||||
description="Sieben digitale Mitarbeiter arbeiten in den Systemen der Bewirtschaftung. Sie führen aus, Sie entscheiden."
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RotateCcw size={14} />}
|
|
||||||
disabled={resetDemo.isPending}
|
|
||||||
onClick={() => resetDemo.mutate()}
|
|
||||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{resetDemo.isPending ? 'Wird zurückgesetzt …' : 'Demo zurücksetzen'}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 5 }}>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} />
|
<ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TeamKpiSection agents={agents} />
|
<Box component="section" sx={{ pt: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1 }}>
|
||||||
<Box component="section" sx={{ mb: 4 }}>
|
<TeamLevelTabs value={level} onChange={setLevel} />
|
||||||
<TeamSectionHeader
|
|
||||||
title="Kernteam"
|
|
||||||
description="Alle sieben digitalen Mitarbeiter mit Zuständigkeit und aktueller Kennzahl."
|
|
||||||
quickLink={{ label: 'Zur Personalverwaltung', to: ROUTES.SUPPLY.TEAM_PERSONNEL }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gap: 1.5,
|
|
||||||
gridTemplateColumns: {
|
|
||||||
xs: '1fr',
|
|
||||||
sm: 'repeat(2, 1fr)',
|
|
||||||
lg: 'repeat(3, 1fr)',
|
|
||||||
xl: 'repeat(4, 1fr)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? [0, 1, 2, 3].map((i) => <CardSkeleton key={i} />)
|
|
||||||
: agents.map((agent) => (
|
|
||||||
<AgentCard key={agent.id} agent={agent} onOpen={openAgent} />
|
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<TeamOverviewRing level={level} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box component="section">
|
<Box component="section" sx={{ mt: 6 }}>
|
||||||
|
<TeamKpiSection agents={agents} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box component="section" sx={{ mt: 2 }}>
|
||||||
<TeamSectionHeader
|
<TeamSectionHeader
|
||||||
title="Kanäle & Systeme"
|
title="Kanäle & Systeme"
|
||||||
description="Womit das digitale Team heute verbunden ist."
|
description="Womit das digitale Team heute verbunden ist."
|
||||||
|
|||||||
@@ -10,3 +10,21 @@ import { cleanup } from '@testing-library/react'
|
|||||||
* Datei an «found multiple elements».
|
* Datei an «found multiple elements».
|
||||||
*/
|
*/
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom bringt keinen `ResizeObserver` mit. Komponenten, die ihre Breite messen
|
||||||
|
* — etwa die Kreisdarstellung der Belegschaft —, würden sonst schon beim Mounten
|
||||||
|
* mit einem ReferenceError abbrechen.
|
||||||
|
*
|
||||||
|
* Der Ersatz beobachtet nichts, er hält nur die Schnittstelle bereit. Die
|
||||||
|
* Komponenten sind so gebaut, dass sie ohne Messwerte auf eine sinnvolle
|
||||||
|
* Referenzbreite zurückfallen.
|
||||||
|
*/
|
||||||
|
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||||
|
class ResizeObserverStub implements ResizeObserver {
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver = ResizeObserverStub
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
// Die Standardgrenze von 5 s ist für diese Suite zu knapp: die Komponenten-
|
||||||
|
// tests rendern ganze Ansichten samt Porträts, und bei parallel laufenden
|
||||||
|
// Dateien kippten einzelne Tests in die Zeitüberschreitung, obwohl sie
|
||||||
|
// einzeln in Bruchteilen einer Sekunde durchlaufen.
|
||||||
|
testTimeout: 20000,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'html'],
|
reporter: ['text', 'html'],
|
||||||
|
|||||||