feat(team): Oberfläche der Teamübersicht

Vier Seiten, eingebettet in die bestehende App-Shell — keine zweite Sidebar,
keine zweite Kopfzeile, kein eigenes Property-On-Branding:

- Teamübersicht: Auswertung (drei Kennzahlen, gemeinsam filterbar nach Zeitraum,
  Bereich und Mitarbeiter), Kernteam, Kanäle & Systeme — in dieser Reihenfolge
- Bearbeitungsverlauf: zwei Reiter, neun Filterdimensionen, Detail-Drawer mit
  Ergebnis, Eingabedaten, Verarbeitungsschritten, Fundstellen, Nachrichtenverlauf
  und Aktionshistorie; Freigeben, Anpassen, Zurückweisen, Rückfrage beantworten
- Personalverwaltung: dreigeteilt (Hauptnavigation, Agentenliste, Dossier) mit
  fünf Reitern — Aufgaben, Kanäle, Systeme, Einstellungen, Protokoll
- Kanäle & Systeme: neun Verbindungen, mehrstufiger Assistent, ERP-Auswahl,
  deterministischer Verbindungstest

Navigation: NavItem um `children` erweitert, "Teamübersicht" steht exakt zwischen
"Meine Objekte" und "Reminder Manager", die drei Subreiter bleiben eingerückt
sichtbar, solange man im Funktionsbereich ist.

Subreiter sind echte Routen, keine lokalen Tabs. Die App kannte bisher keine
Feature-Route mit eigenem Outlet und baut Tabs sonst über lokalen useState — hier
trug das nicht: die Subreiter müssen in der Sidebar stehen und deep-linkbar sein.
Die Routen sind flache Geschwister, kein Outlet-Baum.

Freigabepflichtige Aktionen laufen über einen Bestätigungsdialog mit sichtbarer
Konsequenz; die 1-Klick-Bestätigung ist die fachlich vorgesehene Ausnahme. Jeder
Status trägt neben der Farbe immer einen Text.

Null rohe Hex-Werte: check:tokens bleibt unverändert bei 2249. Der in der
Spezifikation genannte dunkelblaue Banner "Heute geleistet …" existiert in dieser
Codebasis nicht — er wird deshalb auch nicht erzeugt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-08-03 16:56:00 +02:00
parent 52dc7de25e
commit 78e2dab655
39 changed files with 5638 additions and 2 deletions
+18
View File
@@ -30,6 +30,12 @@ const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'
const NewListing = lazy(() => import('./pages/supply/NewListing')) const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings')) const MyListings = lazy(() => import('./pages/supply/MyListings'))
// Property On — «Teamübersicht»
const Teamuebersicht = lazy(() => import('./pages/supply/Teamuebersicht'))
const Personalverwaltung = lazy(() => import('./pages/supply/Personalverwaltung'))
const Bearbeitungsverlauf = lazy(() => import('./pages/supply/Bearbeitungsverlauf'))
const KanaeleSysteme = lazy(() => import('./pages/supply/KanaeleSysteme'))
const AISearch = lazy(() => import('./pages/demand/AISearch')) const AISearch = lazy(() => import('./pages/demand/AISearch'))
const Results = lazy(() => import('./pages/demand/Results')) const Results = lazy(() => import('./pages/demand/Results'))
const MatchDetail = lazy(() => import('./pages/demand/MatchDetail')) const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
@@ -64,6 +70,18 @@ function App() {
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} /> <Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} /> <Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} /> <Route path="/supply/my-listings" element={<MyListings />} />
{/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4).
Bewusst flache Geschwisterrouten statt <Outlet/>: die App kennt
keine einzige Feature-Route mit eigenem Outlet, ein Novum hier
würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */}
<Route path="/supply/team" element={<Teamuebersicht />} />
<Route path="/supply/team/personalverwaltung" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId/:tab" element={<Personalverwaltung />} />
<Route path="/supply/team/bearbeitungsverlauf" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/bearbeitungsverlauf/:tab" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/kanaele-systeme" element={<KanaeleSysteme />} />
</Route> </Route>
{/* Demand Workspace */} {/* Demand Workspace */}
+52 -2
View File
@@ -1,6 +1,6 @@
import { Box, Typography, Avatar, IconButton, Tooltip } from '@mui/material' import { Box, Typography, Avatar, IconButton, Tooltip } from '@mui/material'
import { ChevronLeft, ChevronRight } from 'lucide-react' import { ChevronLeft, ChevronRight } from 'lucide-react'
import { NavLink } from 'react-router' import { NavLink, useLocation } from 'react-router'
import { WorkspaceType } from '../../domain/enums' import { WorkspaceType } from '../../domain/enums'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig' import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig'
@@ -48,6 +48,7 @@ export function Sidebar({
}: SidebarProps) { }: SidebarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace] const config = WORKSPACE_CONFIG[activeWorkspace]
const compareCount = useCompareStore(s => s.compareItems.length) const compareCount = useCompareStore(s => s.compareItems.length)
const { pathname } = useLocation()
const width = collapsed ? 60 : 264 const width = collapsed ? 60 : 264
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws)) const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
@@ -235,12 +236,61 @@ export function Sidebar({
</NavLink> </NavLink>
) )
// Subreiter bleiben sichtbar, solange der Nutzer im Funktionsbereich ist.
// Eingeklappt entfallen sie — dort ist kein Platz für eine zweite Ebene.
const subItems = item.children ?? []
const inSection = subItems.length > 0 && pathname.startsWith(item.path)
return collapsed ? ( return collapsed ? (
<Tooltip key={item.path} title={item.label} placement="right"> <Tooltip key={item.path} title={item.label} placement="right">
<Box>{navContent}</Box> <Box>{navContent}</Box>
</Tooltip> </Tooltip>
) : ( ) : (
<Box key={item.path}>{navContent}</Box> <Box key={item.path}>
{navContent}
{inSection && (
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, mt: 0.25, mb: 0.5 }}>
{subItems.map((child) => (
<Box component="li" key={child.path}>
<NavLink
to={child.path}
style={{ textDecoration: 'none', display: 'block' }}
onClick={() => onClose?.()}
>
{({ isActive }) => (
<Box
sx={{
pl: 4.75,
pr: 1.5,
py: 0.5,
mx: 0.5,
borderRadius: 1,
borderLeft: `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
'&:hover': { backgroundColor: NAV_HOVER_BG },
transition: 'border-color 0.15s ease',
cursor: 'pointer',
}}
>
<Typography
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 600 : 400,
fontSize: '0.8125rem',
letterSpacing: '-0.01em',
whiteSpace: 'nowrap',
}}
>
{child.label}
</Typography>
</Box>
)}
</NavLink>
</Box>
))}
</Box>
)}
</Box>
) )
})} })}
</Box> </Box>
+35
View File
@@ -1,4 +1,5 @@
import { WorkspaceType } from '../../domain/enums' import { WorkspaceType } from '../../domain/enums'
import { ROUTES } from '../../lib/constants'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
LayoutDashboard, LayoutDashboard,
@@ -13,16 +14,32 @@ import {
Kanban, Kanban,
BellRing, BellRing,
Settings, Settings,
Users,
} from 'lucide-react' } from 'lucide-react'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Eingerückter Subreiter unterhalb eines Hauptreiters. Bewusst ohne Icon: die
* Einrückung und der schmalere Schriftgrad machen die Hierarchie deutlich,
* ein zweites Icon-Raster würde die Sidebar unruhig machen.
*/
export interface NavSubItem {
path: string
label: string
}
export interface NavItem { export interface NavItem {
path: string path: string
label: string label: string
icon: LucideIcon icon: LucideIcon
/**
* Subreiter, die sichtbar bleiben, solange sich der Nutzer innerhalb des
* Funktionsbereichs befindet (Property On, §3.3).
*/
children?: NavSubItem[]
} }
export interface WorkspaceConfig { export interface WorkspaceConfig {
@@ -48,6 +65,16 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
navItems: [ navItems: [
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
{
path: ROUTES.SUPPLY.TEAM,
label: 'Teamübersicht',
icon: Users,
children: [
{ path: ROUTES.SUPPLY.TEAM_PERSONNEL, label: 'Personalverwaltung' },
{ path: ROUTES.SUPPLY.TEAM_HISTORY, label: 'Bearbeitungsverlauf' },
{ path: ROUTES.SUPPLY.TEAM_CONNECTIONS, label: 'Kanäle & Systeme' },
],
},
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, { path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare }, { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
@@ -102,8 +129,16 @@ export function getPageNameFromPath(pathname: string): string {
for (const ws of Object.values(WORKSPACE_CONFIG)) { for (const ws of Object.values(WORKSPACE_CONFIG)) {
for (const item of ws.navItems) { for (const item of ws.navItems) {
if (item.path === pathname) return item.label if (item.path === pathname) return item.label
for (const child of item.children ?? []) {
if (child.path === pathname) return child.label
} }
} }
}
// Property On: Deep-Links auf Personalblatt und Verlaufsreiter. Ohne diese Fälle
// fiele der Titel in den Segment-Fallback und ergäbe «Personalverwaltung» statt
// des Mitarbeiternamens bzw. «Erledigte auftraege».
if (/^\/supply\/team\/personalverwaltung\/.+/.test(pathname)) return 'Personalblatt'
if (/^\/supply\/team\/bearbeitungsverlauf\/.+/.test(pathname)) return 'Bearbeitungsverlauf'
if (/^\/demand\/results\/.+/.test(pathname)) return 'Match Detail' if (/^\/demand\/results\/.+/.test(pathname)) return 'Match Detail'
if (/^\/demand\/property\//.test(pathname)) return 'Objekt Detail' if (/^\/demand\/property\//.test(pathname)) return 'Objekt Detail'
if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail' if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail'
+87
View File
@@ -0,0 +1,87 @@
import { memo } from 'react'
import { Avatar, Box, Tooltip } from '@mui/material'
import { Check } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentStatus } from '../../domain/teamAgent'
import { AVATAR_TONE_COLOR } from './teamTokens'
import { AGENT_STATUS_LABELS } from '../../lib/constants'
import { DS_BG, DS_TEXT } from '../../lib/ds'
const SIZES = {
small: 28,
medium: 40,
large: 64,
} as const
export type AgentAvatarSize = keyof typeof SIZES
interface Props {
agent: TeamAgent
size?: AgentAvatarSize
/**
* Blendet den Statuspunkt ein. Auf der Teamübersicht ist er verbindlich (§6.2):
* ein grüner Haken für aktiv, sonst der Hinweis «Pausiert».
*/
showStatus?: boolean
}
function initialsOf(name: string): string {
return name.trim().slice(0, 2).toUpperCase()
}
/**
* Es gibt keine Porträtbilder für digitale Mitarbeiter — und erfundene
* Gesichter wären in einer Kundendemo irreführend. Stattdessen Initialen auf
* einer je Mitarbeiter festen Farbe.
*/
export const AgentAvatar = memo(function AgentAvatar({ agent, size = 'medium', showStatus = false }: Props) {
const px = SIZES[size]
const isActive = agent.status === AgentStatus.ACTIVE
const statusLabel = AGENT_STATUS_LABELS[agent.status] ?? agent.status
const badgePx = size === 'large' ? 20 : 14
return (
<Box sx={{ position: 'relative', width: px, height: px, flexShrink: 0 }}>
{/* `alt` reicht MUI ausschliesslich an den img-Slot weiter, und ohne `src`
rendert Avatar gar kein <img> — die Beschriftung landete damit nirgends
im DOM. Der zugängliche Name muss deshalb direkt auf das Element. */}
<Avatar
role="img"
aria-label={`${agent.name}, ${agent.role}`}
sx={{
width: px,
height: px,
bgcolor: AVATAR_TONE_COLOR[agent.avatarTone],
color: DS_TEXT.inverted,
fontSize: px * 0.38,
fontWeight: 700,
}}
>
{initialsOf(agent.name)}
</Avatar>
{showStatus && (
<Tooltip title={statusLabel} arrow>
<Box
aria-label={statusLabel}
sx={{
position: 'absolute',
right: -2,
bottom: -2,
width: badgePx,
height: badgePx,
borderRadius: '50%',
border: `2px solid ${DS_BG.surface}`,
bgcolor: isActive ? DS_TEXT.success : DS_TEXT.warning,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{isActive && <Check size={badgePx - 7} color={DS_TEXT.inverted} strokeWidth={4} />}
</Box>
</Tooltip>
)}
</Box>
)
})
+121
View File
@@ -0,0 +1,121 @@
/**
* Property On — Badge-Familie.
*
* Alle bauen auf `GenericBadge` auf, damit Property On exakt wie der Rest der
* Anwendung aussieht. Jeder Status trägt zusätzlich zur Farbe einen Text —
* Farbe allein ist kein zulässiger Statusträger (§2.3, §18).
*/
import { GenericBadge } from '../shared/GenericBadge'
import type {
AgentStatus,
AgentConnectionStatus,
AgentAccessLevel,
AgentAutonomyLevel,
AgentChannelType,
} from '../../domain/teamAgent'
import type { AgentWorkItemPriority, AgentWorkItemStatus } from '../../domain/agentWorkItem'
import {
AGENT_STATUS_LABELS,
AGENT_PRIORITY_LABELS,
AGENT_WORK_ITEM_STATUS_LABELS,
AGENT_CONNECTION_STATUS_LABELS,
AGENT_ACCESS_LABELS,
AGENT_AUTONOMY_LABELS,
AGENT_CHANNEL_LABELS,
} from '../../lib/constants'
import {
AGENT_STATUS_VARIANT,
PRIORITY_VARIANT,
WORK_ITEM_STATUS_VARIANT,
CONNECTION_STATUS_VARIANT,
ACCESS_VARIANT,
} from './teamTokens'
type Size = 'small' | 'medium'
export function AgentStatusBadge({ status, size = 'small' }: { status: AgentStatus; size?: Size }) {
return (
<GenericBadge
label={AGENT_STATUS_LABELS[status] ?? status}
semanticVariant={AGENT_STATUS_VARIANT[status]}
size={size}
/>
)
}
export function AgentPriorityBadge({ priority, size = 'small' }: { priority: AgentWorkItemPriority; size?: Size }) {
return (
<GenericBadge
label={AGENT_PRIORITY_LABELS[priority] ?? priority}
semanticVariant={PRIORITY_VARIANT[priority]}
bold
size={size}
/>
)
}
export function AgentWorkItemStatusBadge({ status, size = 'small' }: { status: AgentWorkItemStatus; size?: Size }) {
return (
<GenericBadge
label={AGENT_WORK_ITEM_STATUS_LABELS[status] ?? status}
semanticVariant={WORK_ITEM_STATUS_VARIANT[status]}
size={size}
/>
)
}
export function AgentConnectionStatusBadge({
status,
size = 'small',
}: {
status: AgentConnectionStatus
size?: Size
}) {
return (
<GenericBadge
label={AGENT_CONNECTION_STATUS_LABELS[status] ?? status}
semanticVariant={CONNECTION_STATUS_VARIANT[status]}
size={size}
/>
)
}
export function AgentAccessBadge({ access, size = 'small' }: { access: AgentAccessLevel; size?: Size }) {
return (
<GenericBadge
label={AGENT_ACCESS_LABELS[access] ?? access}
semanticVariant={ACCESS_VARIANT[access]}
size={size}
/>
)
}
export function AgentAutonomyBadge({
autonomy,
note,
size = 'small',
}: {
autonomy: AgentAutonomyLevel
note?: string
size?: Size
}) {
return (
<GenericBadge
label={AGENT_AUTONOMY_LABELS[autonomy] ?? autonomy}
semanticVariant="verified"
size={size}
tooltip={note}
/>
)
}
export function AgentChannelBadge({ channel, size = 'small' }: { channel: AgentChannelType; size?: Size }) {
return (
<GenericBadge
label={AGENT_CHANNEL_LABELS[channel] ?? channel}
semanticVariant="muted"
size={size}
/>
)
}
+98
View File
@@ -0,0 +1,98 @@
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>
)
})
+249
View File
@@ -0,0 +1,249 @@
/**
* Property On — Registerkarte «Kanäle» im Personalblatt (§9).
*
* Beantwortet eine Frage: Über welche Wege erhält dieser digitale Mitarbeiter
* seine Aufträge und über welche liefert er seine Ergebnisse aus?
*
* Der Aktiv-Schalter wirkt sofort und ohne Speichern-Knopf — ein Kanal ist ein
* Ein/Aus-Entscheid, kein Formular. Der Konfigurationsdialog dagegen ist eine
* reine Frontend-Simulation (§9.5): nur lokaler Zustand, keine Zugangsdaten.
*/
import { memo, useCallback, useMemo, useState } from 'react'
import {
Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
FormControlLabel, Switch, TextField, Typography,
} from '@mui/material'
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Settings2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { AgentChannel, TeamAgent } from '../../domain/teamAgent'
import { AgentChannelDirection, AgentConnectionStatus } from '../../domain/teamAgent'
import { AgentConnectionStatusBadge } from './AgentBadges'
import { AGENT_CHANNEL_DIRECTION_LABELS, AGENT_CHANNEL_LABELS } from '../../lib/constants'
import { useSaveAgentChannels } from '../../hooks/useTeamAgents'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
const DIRECTION_ICON: Record<AgentChannelDirection, LucideIcon> = {
[AgentChannelDirection.INBOUND]: ArrowDownLeft,
[AgentChannelDirection.OUTBOUND]: ArrowUpRight,
[AgentChannelDirection.BOTH]: ArrowLeftRight,
}
const FOCUS_RING = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
interface TestResult { ok: boolean; message: string }
/** Deterministisch statt zufällig: verbundene Kanäle gelingen, alle anderen scheitern nachvollziehbar. */
const TEST_RESULT_BY_STATUS: Record<AgentConnectionStatus, TestResult> = {
[AgentConnectionStatus.CONNECTED]: { ok: true, message: 'Verbindung erfolgreich. Der Systemzugang antwortet — Aufträge werden entgegengenommen und Ergebnisse können ausgeliefert werden.' },
[AgentConnectionStatus.DISCONNECTED]: { ok: false, message: 'Verbindung fehlgeschlagen. Für diesen Kanal ist kein Systemzugang hinterlegt; die Bewirtschaftung muss ihn zuerst freigeben.' },
[AgentConnectionStatus.ROADMAP]: { ok: false, message: 'Verbindung fehlgeschlagen. Dieser Kanal ist erst geplant und noch nicht eingerichtet — ein Test ist nach der Einrichtung möglich.' },
}
// ── Kanalzeile ────────────────────────────────────────────────────────────────
interface RowProps {
channel: AgentChannel
onToggle: (channelId: string, enabled: boolean) => void
onConfigure: (channelId: string) => void
}
const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }: RowProps) {
const label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type
const DirectionIcon = DIRECTION_ICON[channel.direction]
return (
<Box
component="li"
sx={{
border: `1px solid ${DS_BORDER.default}`, borderRadius: 2, bgcolor: DS_BG.surface,
boxShadow: DS_SHADOW.card, p: 1.75, display: 'grid', gap: 0.75,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary }}>{label}</Typography>
{channel.optional && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>«optional»</Typography>
)}
<AgentConnectionStatusBadge status={channel.status} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: { sm: 'auto' } }}>
<DirectionIcon size={14} color={DS_TEXT.secondary} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction}
</Typography>
</Box>
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{channel.description}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 0.25 }}>
<Switch
size="small"
checked={channel.enabled}
onChange={(_, checked) => onToggle(channel.id, checked)}
slotProps={{ input: { 'aria-label': `Kanal ${label} aktivieren` } }}
/>
<Typography variant="caption" sx={{ color: channel.enabled ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 600 }}>
{channel.enabled ? 'Aktiv' : 'Inaktiv'}
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<Settings2 size={14} aria-hidden />}
aria-label={`Konfiguration für ${label} öffnen`}
onClick={() => onConfigure(channel.id)}
sx={{ ml: 'auto', ...FOCUS_RING }}
>
Konfiguration öffnen
</Button>
</Box>
</Box>
)
})
// ── Konfigurationsdialog ──────────────────────────────────────────────────────
/** Formularmodell des Dialogs — bewusst nicht `AgentChannelConfig`: Empfänger sind hier ein Fliesstext. */
interface ChannelFormDraft {
displayName: string
senderAddress: string
inboxAddress: string
recipients: string
autoReply: boolean
enabled: boolean
}
/**
* Das Formular ist bewusst eine eigene Komponente: Der Dialog hängt es beim
* Schliessen ab, wodurch der Entwurf ohne Effekt und ohne Zurücksetzen-Logik
* immer wieder vom hinterlegten Stand ausgeht.
*/
function ChannelConfigForm({ channel, onClose }: { channel: AgentChannel; onClose: () => void }) {
const [draft, setDraft] = useState<ChannelFormDraft>(() => ({
displayName: channel.config.displayName,
senderAddress: channel.config.senderAddress ?? '',
inboxAddress: channel.config.inboxAddress ?? '',
recipients: channel.config.defaultRecipients.join(', '),
autoReply: channel.config.autoReplyEnabled,
enabled: channel.enabled,
}))
const [testing, setTesting] = useState(false)
const [result, setResult] = useState<TestResult | null>(null)
const label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type
const runTest = useCallback(async () => {
setTesting(true)
setResult(null)
await new Promise<void>((resolve) => { window.setTimeout(resolve, 600) })
setResult(TEST_RESULT_BY_STATUS[channel.status])
setTesting(false)
}, [channel.status])
return (
<>
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem' }}>Kanal «{label}» konfigurieren</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}>
Frontend-Simulation zur Veranschaulichung: Die Angaben werden nicht gespeichert und es werden
keine echten Zugangsdaten erfasst.
</Typography>
<Box sx={{ display: 'grid', gap: 2, mt: 1 }}>
<TextField fullWidth size="small" label="Anzeigename" value={draft.displayName}
onChange={(e) => setDraft({ ...draft, displayName: e.target.value })} />
<TextField fullWidth size="small" label="Absenderadresse" value={draft.senderAddress}
onChange={(e) => setDraft({ ...draft, senderAddress: e.target.value })} />
<TextField fullWidth size="small" label="Eingangsadresse" value={draft.inboxAddress}
onChange={(e) => setDraft({ ...draft, inboxAddress: e.target.value })} />
<TextField fullWidth size="small" label="Standardempfänger" helperText="Mehrere Adressen mit Komma trennen"
value={draft.recipients} onChange={(e) => setDraft({ ...draft, recipients: e.target.value })} />
<FormControlLabel
control={<Switch size="small" checked={draft.enabled}
onChange={(_, checked) => setDraft({ ...draft, enabled: checked })} />}
label={`Kanal aktiv — ${draft.enabled ? 'nimmt Aufträge entgegen' : 'nimmt derzeit nichts entgegen'}`}
/>
<FormControlLabel
control={<Switch size="small" checked={draft.autoReply}
onChange={(_, checked) => setDraft({ ...draft, autoReply: checked })} />}
label={`Antwortfreigabe — ${draft.autoReply ? 'Antworten gehen direkt raus' : 'Antworten benötigen eine Freigabe'}`}
/>
</Box>
{result && <Alert severity={result.ok ? 'success' : 'error'} sx={{ mt: 2 }}>{result.message}</Alert>}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button size="small" variant="outlined" disabled={testing} sx={FOCUS_RING} onClick={() => { void runTest() }}>
{testing ? 'Verbindung wird geprüft …' : 'Verbindung testen'}
</Button>
<Button size="small" variant="contained" onClick={onClose} sx={FOCUS_RING}>Schliessen</Button>
</DialogActions>
</>
)
}
function ChannelConfigDialog({ channel, onClose }: { channel: AgentChannel | null; onClose: () => void }) {
return (
<Dialog open={channel !== null} onClose={onClose} maxWidth="sm" fullWidth>
{channel && <ChannelConfigForm channel={channel} onClose={onClose} />}
</Dialog>
)
}
// ── Registerkarte ─────────────────────────────────────────────────────────────
export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
const { mutate: saveChannels } = useSaveAgentChannels()
const [openChannelId, setOpenChannelId] = useState<string | null>(null)
const channels = agent.channels
// Beide Kennzahlen in einem Durchlauf (§10.4).
const summary = useMemo(() => {
let active = 0
let connected = 0
for (const channel of channels) {
if (channel.enabled) active += 1
if (channel.status === AgentConnectionStatus.CONNECTED) connected += 1
}
return { active, connected }
}, [channels])
const openChannel = useMemo(() => channels.find((c) => c.id === openChannelId) ?? null, [channels, openChannelId])
const handleToggle = useCallback(
(channelId: string, enabled: boolean) => {
saveChannels({ id: agent.id, channels: channels.map((c) => (c.id === channelId ? { ...c, enabled } : c)) })
},
[agent.id, channels, saveChannels],
)
const handleConfigure = useCallback((channelId: string) => setOpenChannelId(channelId), [])
const handleClose = useCallback(() => setOpenChannelId(null), [])
return (
<Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus.
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25, mb: 1.5 }}>
{summary.active} von {channels.length} Kanälen aktiv · {summary.connected} verbunden
</Typography>
{channels.length === 0 ? (
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>
Für {agent.name} ist noch kein Kanal hinterlegt.
</Typography>
) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 1.25 }}>
{channels.map((channel) => (
<ChannelRow key={channel.id} channel={channel} onToggle={handleToggle} onConfigure={handleConfigure} />
))}
</Box>
)}
<ChannelConfigDialog channel={openChannel} onClose={handleClose} />
</Box>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { Box, FormControlLabel, Switch, Typography } from '@mui/material'
import { AlertTriangle, Mail } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentStatus } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar'
import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges'
import { ConfirmDialog } from '../ui'
import { useSetAgentActive } from '../../hooks/useTeamAgents'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
interface Props {
agent: TeamAgent
}
function MetaField({ label, value }: { label: string; value: string }) {
return (
<Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{value}
</Typography>
</Box>
)
}
/**
* Kopfbereich des Personalblatts (§7.3).
*
* Das Pausieren läuft über einen Bestätigungsdialog mit Hinweis auf die
* betroffenen Aufgaben — ein versehentlich pausierter Fristen-Wächter würde
* echte Termine verpassen (§7.5, §17.3).
*/
export function AgentDossierHeader({ agent }: Props) {
const [confirmPause, setConfirmPause] = useState(false)
const setActive = useSetAgentActive()
const isActive = agent.status === AgentStatus.ACTIVE
const enabledTasks = agent.tasks.filter(t => t.enabled).length
const handleToggle = (next: boolean) => {
if (next) setActive.mutate({ id: agent.id, active: true })
else setConfirmPause(true)
}
return (
<Box sx={{ px: 3, pt: 2.5, pb: 2, bgcolor: DS_BG.surface, borderBottom: `1px solid ${DS_BORDER.default}` }}>
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<AgentAvatar agent={agent} size="large" showStatus />
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.25rem', color: DS_TEXT.primary, lineHeight: 1.25 }}>
{agent.name}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{agent.role}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Mail size={13} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>{agent.email}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
<AgentStatusBadge status={agent.status} />
<AgentAutonomyBadge autonomy={agent.autonomyLevel} note={agent.autonomyNote} />
</Box>
</Box>
<FormControlLabel
control={
<Switch
checked={isActive}
disabled={setActive.isPending}
onChange={(e) => handleToggle(e.target.checked)}
slotProps={{ input: { 'aria-label': `${agent.name} aktiv` } }}
/>
}
label="Agent aktiv"
sx={{ mr: 0, '& .MuiFormControlLabel-label': { fontWeight: 600, fontSize: '0.875rem' } }}
/>
</Box>
{/* Stammdaten */}
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
mt: 2,
pt: 2,
borderTop: `1px solid ${DS_BORDER.muted}`,
}}
>
<MetaField label="Personalnummer" value={agent.personnelNumber} />
<MetaField label="Abteilung" value={agent.department} />
<MetaField label="Autonomiegrad" value={agent.autonomyNote} />
<MetaField
label="Letzter Lauf"
value={agent.lastRun ? formatTeamDateTime(agent.lastRun) : 'Noch nicht gelaufen'}
/>
</Box>
{/* Warnung im Personalblatt, solange pausiert (§7.5) */}
{!isActive && (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'flex-start',
mt: 2,
p: 1.5,
borderRadius: 2,
bgcolor: DS_SURFACE.warning.bg,
border: `1px solid ${DS_SURFACE.warning.border}`,
}}
>
<AlertTriangle size={16} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark }}>
{agent.name} ist pausiert. Bis zur Reaktivierung werden keine Aufgaben ausgeführt und es
entstehen keine neuen Vorgänge.
</Typography>
</Box>
)}
<ConfirmDialog
open={confirmPause}
destructive
title={`${agent.name} pausieren?`}
message={`${enabledTasks} aktive Aufgaben werden ausgesetzt, bis Sie ${agent.name} wieder aktivieren. Laufende Fristen und Anfragen werden in dieser Zeit nicht bearbeitet. Der Vorgang wird protokolliert.`}
confirmLabel="Pausieren"
onConfirm={() => {
setActive.mutate({ id: agent.id, active: false })
setConfirmPause(false)
}}
onCancel={() => setConfirmPause(false)}
/>
</Box>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { Box, Typography } from '@mui/material'
import { ArrowRight, LogIn, LogOut, Target } from 'lucide-react'
import type { AgentProfile } from '../../domain/teamAgent'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
interface ColumnProps {
icon: React.ReactNode
title: string
items: string[]
}
function Column({ icon, title, items }: ColumnProps) {
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
{icon}
<Typography
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
}}
>
{title}
</Typography>
</Box>
<Box component="ul" sx={{ m: 0, pl: 2.25, display: 'grid', gap: 0.375 }}>
{items.map((item) => (
<Typography component="li" variant="body2" key={item} sx={{ color: DS_TEXT.secondary }}>
{item}
</Typography>
))}
</Box>
</Box>
)
}
/**
* Gemeinsame Informationsbox unter dem Kopfbereich (§7.6).
*
* Zweck, Input, Kernablauf und Output in Alltagssprache — sie erklärt einem
* Bewirtschafter in zehn Sekunden, was dieser digitale Mitarbeiter tut, ohne
* dass er sich durch die Aufgabenliste lesen muss.
*/
export function AgentInfoBox({ profile }: { profile: AgentProfile }) {
return (
<Box
sx={{
mx: 3,
mt: 2,
p: 2,
borderRadius: 2,
bgcolor: DS_BG.subtle,
border: `1px solid ${DS_BORDER.default}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 1.75 }}>
<Target size={15} color={DS_TEXT.brand} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{profile.purpose}
</Typography>
</Box>
<Box
sx={{
display: 'grid',
gap: 2,
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' },
}}
>
<Column
icon={<LogIn size={13} color={DS_TEXT.muted} aria-hidden />}
title="Input"
items={profile.input}
/>
<Column
icon={<ArrowRight size={13} color={DS_TEXT.muted} aria-hidden />}
title="Kernablauf"
items={profile.coreFlow}
/>
<Column
icon={<LogOut size={13} color={DS_TEXT.muted} aria-hidden />}
title="Output"
items={profile.output}
/>
</Box>
</Box>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { memo } from 'react'
import { Box, MenuItem, TextField, Typography } from '@mui/material'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar'
import { AgentStatusBadge } from './AgentBadges'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
interface RowProps {
agent: TeamAgent
selected: boolean
onSelect: (id: string) => void
}
const AgentListRow = memo(function AgentListRow({ agent, selected, onSelect }: RowProps) {
return (
<Box
component="button"
type="button"
aria-current={selected ? 'true' : undefined}
onClick={() => onSelect(agent.id)}
sx={{
width: '100%',
textAlign: 'left',
border: 'none',
borderLeft: `3px solid ${selected ? DS_TEXT.brand : 'transparent'}`,
bgcolor: selected ? DS_BG.subtle : 'transparent',
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: 1.75,
py: 1.25,
cursor: 'pointer',
font: 'inherit',
'&:hover': { bgcolor: DS_BG.subtle },
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: -2 },
}}
>
<AgentAvatar agent={agent} size="small" showStatus />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: selected ? 700 : 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
{agent.name}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block' }}>
{agent.role}
</Typography>
</Box>
<AgentStatusBadge status={agent.status} />
</Box>
)
})
interface Props {
agents: TeamAgent[]
selectedId: string | null
onSelect: (id: string) => void
/** Auf Tablet und Mobil wird die Liste zur vorgeschalteten Auswahl (§7.2). */
compact?: boolean
}
/**
* Agentenliste innerhalb des Inhaltsbereichs — die mittlere der drei Ebenen aus
* §7.1. Sie ist bewusst als eigene Fläche mit Trennlinie abgesetzt, damit
* Hauptnavigation, Agentenliste und Dossier visuell eindeutig unterscheidbar
* bleiben.
*
* Reihenfolge kommt aus `mockTeamAgents` (alphabetisch nach Vorname, §7.2) und
* wird hier bewusst nicht erneut sortiert.
*/
export function AgentListPanel({ agents, selectedId, onSelect, compact = false }: Props) {
if (compact) {
return (
<Box sx={{ px: 3, py: 2, borderBottom: `1px solid ${DS_BORDER.default}`, bgcolor: DS_BG.surface }}>
<TextField
select
fullWidth
size="small"
label="Digitaler Mitarbeiter"
value={selectedId ?? ''}
onChange={(e) => onSelect(e.target.value)}
>
{agents.map((agent) => (
<MenuItem key={agent.id} value={agent.id}>
{agent.name} · {agent.role}
</MenuItem>
))}
</TextField>
</Box>
)
}
return (
<Box
component="nav"
aria-label="Digitale Mitarbeiter"
sx={{
width: 264,
minWidth: 264,
borderRight: `1px solid ${DS_BORDER.default}`,
bgcolor: DS_BG.surface,
overflowY: 'auto',
flexShrink: 0,
}}
>
<Typography
sx={{
px: 1.75,
pt: 2,
pb: 1,
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
}}
>
Kernteam
</Typography>
{agents.map((agent) => (
<AgentListRow
key={agent.id}
agent={agent}
selected={agent.id === selectedId}
onSelect={onSelect}
/>
))}
</Box>
)
}
+234
View File
@@ -0,0 +1,234 @@
/**
* Property On — Protokoll eines digitalen Mitarbeiters (§12).
*
* Zeitstrahl statt Tabelle: das Protokoll beantwortet «was ist wann passiert und
* wer hat es ausgelöst». Eine Tabelle zwingt den Blick in die Breite und bricht
* auf schmalen Geräten — der Zeitstrahl trägt Reihenfolge, Status und
* aufklappbare Detailtiefe auf Desktop wie Mobil in derselben Form.
*/
import { memo, useCallback, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Box, Button, Collapse, FormControlLabel, InputAdornment, MenuItem, Skeleton, Switch, TextField, Typography } from '@mui/material'
import { AlertTriangle, Building2, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Info, Radio, Search, UserRound, XCircle } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import type { AgentProtocolEntry } from '../../domain/agentProtocol'
import { AgentProtocolEventType, AgentProtocolStatus, AgentTriggerSource } from '../../domain/agentProtocol'
import { AgentPeriod } from '../../domain/agentFilters'
import type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider'
import { useAgentProtocol } from '../../hooks/useAgentProtocol'
import { AGENT_CHANNEL_LABELS, AGENT_PERIOD_LABELS, AGENT_PROTOCOL_EVENT_LABELS, AGENT_PROTOCOL_STATUS_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
import { PROTOCOL_STATUS_SURFACE, PROTOCOL_STATUS_TEXT } from './teamTokens'
import { DetailFieldList, DetailSectionTitle, ProcessingStepList, SourceReferenceList } from './WorkItemDetailSections'
import { EmptyState, ErrorState } from '../ui'
/** Status trägt nie nur Farbe: jeder Zustand hat zusätzlich Symbol und Klartext (§18). */
const STATUS_ICON: Record<AgentProtocolStatus, typeof Info> = {
[AgentProtocolStatus.SUCCESS]: CheckCircle2,
[AgentProtocolStatus.WARNING]: AlertTriangle,
[AgentProtocolStatus.ERROR]: XCircle,
[AgentProtocolStatus.INFO]: Info,
}
const SELECT_SX = { minWidth: 176, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }
const SEARCH_SX = { minWidth: 220, flex: '1 1 220px', maxWidth: 340, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }
const DOT_SX = { width: 30, height: 30, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }
const CARD_SX = { border: `1px solid ${DS_BORDER.default}`, borderRadius: 2, bgcolor: DS_BG.surface, boxShadow: DS_SHADOW.card, p: 1.75 }
const TOGGLE_SX = { textTransform: 'none', fontWeight: 600, mt: 1, px: 0.75, '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
const DETAIL_SX = { display: 'grid', gap: 2, mt: 1.25, pt: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }
const PERIOD_OPTIONS = [AgentPeriod.TODAY, AgentPeriod.WEEK, AgentPeriod.MONTH, AgentPeriod.YEAR]
const SKELETON_ROWS = ['a', 'b', 'c', 'd']
// ── Kleine Bausteine ──────────────────────────────────────────────────────────
type FilterSelectProps<T extends string> = {
label: string; value: T | 'ALL'; options: readonly T[]
labels: Record<string, string>; allLabel: string; onChange: (value: T | 'ALL') => void
}
function FilterSelect<T extends string>({ label, value, options, labels, allLabel, onChange }: FilterSelectProps<T>) {
return (
<TextField select size="small" label={label} value={value} onChange={(e) => onChange(e.target.value as T | 'ALL')} sx={SELECT_SX}>
<MenuItem value="ALL">{allLabel}</MenuItem>
{options.map((option) => <MenuItem key={option} value={option}>{labels[option] ?? option}</MenuItem>)}
</TextField>
)
}
function MetaItem({ icon, text }: { icon: ReactNode; text: string }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
{icon}
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>{text}</Typography>
</Box>
)
}
function DetailBlock({ title, children }: { title: string; children: ReactNode }) {
return <Box><DetailSectionTitle>{title}</DetailSectionTitle>{children}</Box>
}
// ── Ein Eintrag auf dem Zeitstrahl ────────────────────────────────────────────
type RowProps = { entry: AgentProtocolEntry; expanded: boolean; isLast: boolean; onToggle: (id: string) => void }
const ProtocolEntryRow = memo(function ProtocolEntryRow({ entry, expanded, isLast, onToggle }: RowProps) {
const surface = PROTOCOL_STATUS_SURFACE[entry.status]
const tone = PROTOCOL_STATUS_TEXT[entry.status]
const StatusIcon = STATUS_ICON[entry.status]
const detailId = `protokoll-detail-${entry.id}`
const { input, output, processingSteps, sourceReferences } = entry
const hasDetails =
(input?.length ?? 0) > 0 || (processingSteps?.length ?? 0) > 0 ||
(output?.length ?? 0) > 0 || (sourceReferences?.length ?? 0) > 0
const actor = entry.triggeredBy === AgentTriggerSource.USER ? entry.triggeredByName ?? 'Person' : 'Zeitplan'
return (
<Box component="li" sx={{ display: 'flex', gap: 1.5 }}>
{/* Punkt und Verbindungslinie */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
<Box sx={{ ...DOT_SX, bgcolor: surface.bg, border: `1px solid ${surface.border}` }}>
<StatusIcon size={16} color={tone} aria-hidden />
</Box>
{!isLast && <Box aria-hidden sx={{ flex: 1, width: '2px', minHeight: 16, mt: 0.75, bgcolor: DS_BORDER.default }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0, pb: isLast ? 0 : 2 }}>
<Box sx={CARD_SX}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, whiteSpace: 'nowrap' }}>{formatTeamDateTime(entry.timestamp)}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>· {AGENT_PROTOCOL_EVENT_LABELS[entry.eventType] ?? entry.eventType}</Typography>
<Typography variant="caption" sx={{ color: tone, fontWeight: 700, ml: 'auto' }}>{AGENT_PROTOCOL_STATUS_LABELS[entry.status] ?? entry.status}</Typography>
</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.5 }}>{entry.title}</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>{entry.description}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap', mt: 1 }}>
{entry.objectId && <MetaItem icon={<Building2 size={12} color={DS_TEXT.muted} aria-hidden />} text={entry.objectId} />}
{entry.channel && <MetaItem icon={<Radio size={12} color={DS_TEXT.muted} aria-hidden />} text={AGENT_CHANNEL_LABELS[entry.channel] ?? entry.channel} />}
<MetaItem icon={<UserRound size={12} color={DS_TEXT.muted} aria-hidden />} text={actor} />
</Box>
{hasDetails && (
<>
<Button
size="small" sx={TOGGLE_SX} onClick={() => onToggle(entry.id)}
aria-expanded={expanded} aria-controls={detailId}
aria-label={`Details zum Eintrag «${entry.title}» ${expanded ? 'ausblenden' : 'anzeigen'}`}
startIcon={expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
>
{expanded ? 'Details ausblenden' : 'Details anzeigen'}
</Button>
<Collapse in={expanded} unmountOnExit id={detailId}>
<Box sx={DETAIL_SX}>
{input && input.length > 0 && <DetailBlock title="Eingangsdaten"><DetailFieldList fields={input} /></DetailBlock>}
{processingSteps && processingSteps.length > 0 && <DetailBlock title="Verarbeitungsschritte"><ProcessingStepList steps={processingSteps} /></DetailBlock>}
{output && output.length > 0 && <DetailBlock title="Ergebnis"><DetailFieldList fields={output} /></DetailBlock>}
{sourceReferences && sourceReferences.length > 0 && <DetailBlock title="Fundstellen"><SourceReferenceList references={sourceReferences} /></DetailBlock>}
</Box>
</Collapse>
</>
)}
</Box>
</Box>
</Box>
)
})
// ── Reiter ────────────────────────────────────────────────────────────────────
export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
const [period, setPeriod] = useState<AgentPeriod>(AgentPeriod.ALL)
const [eventType, setEventType] = useState<AgentProtocolEventType | 'ALL'>('ALL')
const [status, setStatus] = useState<AgentProtocolStatus | 'ALL'>('ALL')
const [search, setSearch] = useState('')
const [onlyApprovals, setOnlyApprovals] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const filters = useMemo<AgentProtocolFilters>(() => ({
agentId: agent.id,
period,
eventType: eventType === 'ALL' ? undefined : eventType,
status: status === 'ALL' ? undefined : status,
onlyApprovals: onlyApprovals || undefined,
search: search.trim() || undefined,
}), [agent.id, period, eventType, status, onlyApprovals, search])
const { data: entries = [], isLoading, isError, refetch } = useAgentProtocol(filters)
const lastId = entries.length > 0 ? entries[entries.length - 1].id : null
const handleToggle = useCallback((id: string) => setExpandedId((current) => (current === id ? null : id)), [])
const resetFilters = useCallback(() => {
setPeriod(AgentPeriod.ALL)
setEventType('ALL')
setStatus('ALL')
setSearch('')
setOnlyApprovals(false)
}, [])
return (
<Box>
{/* Filterleiste — kompakt in einer Zeile, umbrechend */}
<Box sx={{ display: 'flex', gap: 1.25, flexWrap: 'wrap', alignItems: 'center', mb: 2.5 }}>
<TextField
size="small" sx={SEARCH_SX} value={search} placeholder="Protokoll durchsuchen"
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: { startAdornment: <InputAdornment position="start"><Search size={15} color={DS_TEXT.muted} aria-hidden /></InputAdornment> },
htmlInput: { 'aria-label': 'Protokoll durchsuchen' },
}}
/>
<FilterSelect
label="Zeitraum" value={period === AgentPeriod.ALL ? 'ALL' : period} options={PERIOD_OPTIONS}
labels={AGENT_PERIOD_LABELS} allLabel={AGENT_PERIOD_LABELS.ALL}
onChange={(v) => setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)}
/>
<FilterSelect
label="Ereignisart" value={eventType} options={Object.values(AgentProtocolEventType)}
labels={AGENT_PROTOCOL_EVENT_LABELS} allLabel="Alle Ereignisarten" onChange={setEventType}
/>
<FilterSelect
label="Status" value={status} options={Object.values(AgentProtocolStatus)}
labels={AGENT_PROTOCOL_STATUS_LABELS} allLabel="Alle Status" onChange={setStatus}
/>
<FormControlLabel
control={<Switch size="small" checked={onlyApprovals} onChange={(_, checked) => setOnlyApprovals(checked)} />}
label={<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>Nur Freigaben</Typography>}
sx={{ ml: 0.25, mr: 0 }}
/>
</Box>
{isLoading && (
<Box sx={{ display: 'grid', gap: 1.5 }}>
{SKELETON_ROWS.map((row) => <Skeleton key={row} variant="rounded" height={104} />)}
</Box>
)}
{isError && <ErrorState message="Das Protokoll konnte nicht geladen werden." onRetry={() => { void refetch() }} />}
{!isLoading && !isError && entries.length === 0 && (
<EmptyState
icon={<ClipboardList size={36} />}
title="Keine Protokolleinträge"
description={`Für die gewählten Filter sind keine Aktivitäten von ${agent.name} verzeichnet. Zeitraum erweitern oder Filter zurücksetzen.`}
action={{ label: 'Filter zurücksetzen', onClick: resetFilters }}
/>
)}
{!isLoading && !isError && entries.length > 0 && (
<Box component="ol" aria-label={`Protokoll von ${agent.name}`} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{entries.map((entry) => (
<ProtocolEntryRow key={entry.id} entry={entry} expanded={expandedId === entry.id} isLast={entry.id === lastId} onToggle={handleToggle} />
))}
</Box>
)}
</Box>
)
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Property On — Einstellungen eines digitalen Mitarbeiters (§11).
*
* Gespeichert wird erst auf Knopfdruck: Einstellungen greifen direkt in die
* Bewirtschaftung ein, deshalb keine stillen Sofortänderungen, sondern eine
* sichtbare Leiste mit «Änderungen speichern» und «Verwerfen» (§11.3).
* Gesperrtes bleibt eingeschaltet und wird mit Begründung angezeigt statt
* versteckt — Sinas Quellenzwang und Retos Freigabepflicht sind zwingend.
*/
import { memo, useCallback, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
// prettier-ignore
import { Box, Button, Chip, FormControlLabel, FormHelperText, InputAdornment, MenuItem, Select, Switch, TextField, Typography } from '@mui/material'
import { AlertTriangle, Lock } from 'lucide-react'
import { z } from 'zod'
import type { AgentSetting, TeamAgent } from '../../domain/teamAgent'
import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent'
import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { useSaveAgentSettings } from '../../hooks/useTeamAgents'
type SettingValue = AgentSetting['value']
/** Ab dieser Länge wird ein Textfeld mehrzeilig dargestellt. */
const LONG_TEXT_LENGTH = 80
const NUMBER_MESSAGE = 'Bitte eine Zahl erfassen.'
const FOCUS_SX = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
const ACTION_SX = { textTransform: 'none' as const, fontWeight: 600, ...FOCUS_SX }
const GROUP_TITLE_SX = {
fontWeight: 700, fontSize: '0.75rem', letterSpacing: '0.04em',
textTransform: 'uppercase' as const, color: DS_TEXT.muted,
}
// ── Ableitungen ohne React ────────────────────────────────────────────────────
function isSameValue(current: SettingValue, saved: SettingValue | undefined): boolean {
if (!Array.isArray(current) && !Array.isArray(saved)) return current === saved
const left = Array.isArray(current) ? current : []
const right = Array.isArray(saved) ? saved : []
return left.length === right.length && left.every((entry, index) => entry === right[index])
}
/** Prüfregeln je Einstellung: Zahlenbereich, Pflichttext, mindestens ein Eintrag. */
function buildSettingsSchema(settings: AgentSetting[]) {
const shape: Record<string, z.ZodTypeAny> = {}
for (const setting of settings) {
const suffix = setting.unit ? ` ${setting.unit}` : ''
if (setting.kind === AgentSettingKind.NUMBER) {
let field = z.number({ invalid_type_error: NUMBER_MESSAGE }).finite(NUMBER_MESSAGE)
if (typeof setting.min === 'number') field = field.min(setting.min, `Mindestens ${setting.min}${suffix}.`)
if (typeof setting.max === 'number') field = field.max(setting.max, `Höchstens ${setting.max}${suffix}.`)
shape[setting.id] = field
} else if (setting.kind === AgentSettingKind.MULTI_SELECT) {
shape[setting.id] = z.array(z.string()).min(1, 'Mindestens ein Eintrag ist erforderlich.')
} else if (setting.kind === AgentSettingKind.BOOLEAN) {
shape[setting.id] = z.boolean()
} else {
shape[setting.id] = z.string().trim().min(1, 'Dieses Feld darf nicht leer sein.')
}
}
return z.object(shape)
}
// ── Eine Einstellung ──────────────────────────────────────────────────────────
interface RowProps {
setting: AgentSetting
error?: string
onChange: (id: string, value: SettingValue) => void
}
const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowProps) {
const locked = setting.locked === true
const describedBy = setting.description ? `${setting.id}-desc` : undefined
const aria = { 'aria-label': setting.label, 'aria-describedby': describedBy }
const shared = { fullWidth: true, size: 'small' as const, disabled: locked, error: Boolean(error), helperText: error }
const text = typeof setting.value === 'string' ? setting.value : ''
const long = text.length > LONG_TEXT_LENGTH
const options = setting.options ?? []
const items = options.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)
let control: ReactNode
switch (setting.kind) {
case AgentSettingKind.BOOLEAN:
control = (
<FormControlLabel
label={setting.value === true ? 'Eingeschaltet' : 'Ausgeschaltet'}
slotProps={{ typography: { variant: 'body2', sx: { color: DS_TEXT.secondary } } }}
control={<Switch checked={setting.value === true} disabled={locked} slotProps={{ input: aria }}
onChange={(e) => onChange(setting.id, e.target.checked)} />}
/>
)
break
case AgentSettingKind.NUMBER:
control = (
<TextField {...shared} type="number"
value={typeof setting.value === 'number' && !Number.isNaN(setting.value) ? String(setting.value) : ''}
onChange={(e) => onChange(setting.id, e.target.value.trim() === '' ? Number.NaN : Number(e.target.value))}
slotProps={{
input: setting.unit ? { endAdornment: <InputAdornment position="end">{setting.unit}</InputAdornment> } : undefined,
htmlInput: { min: setting.min, max: setting.max, ...aria },
}} />
)
break
case AgentSettingKind.SELECT:
control = (
<TextField {...shared} select value={text} slotProps={{ select: aria }}
onChange={(e) => onChange(setting.id, e.target.value)}>{items}</TextField>
)
break
case AgentSettingKind.MULTI_SELECT:
control = (
<>
<Select multiple fullWidth size="small" disabled={locked} error={Boolean(error)} {...aria}
value={Array.isArray(setting.value) ? setting.value : []}
onChange={(e) => onChange(setting.id, typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value)}
renderValue={(picked) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{picked.map((v) => <Chip key={v} size="small" label={options.find((o) => o.value === v)?.label ?? v} />)}
</Box>
)}>
{items}
</Select>
{error && <FormHelperText error>{error}</FormHelperText>}
</>
)
break
default:
control = (
<TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined}
slotProps={{ htmlInput: aria }} onChange={(e) => onChange(setting.id, e.target.value)} />
)
}
return (
<Box sx={{ display: 'grid', gap: 0.5, py: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography>
{setting.description && (
<Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography>
)}
<Box sx={{ maxWidth: 560, mt: 0.25 }}>{control}</Box>
{locked && (
<Box sx={{ display: 'flex', gap: 0.75, p: 1, borderRadius: 1.5,
bgcolor: DS_SURFACE.warning.bg, border: `1px solid ${DS_SURFACE.warning.border}` }}>
<Lock size={13} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark }}>
Gesperrt {setting.lockedReason ?? 'Diese Einstellung ist fachlich zwingend und bleibt eingeschaltet.'}
</Typography>
</Box>
)}
</Box>
)
})
// ── Reiter ────────────────────────────────────────────────────────────────────
export function AgentSettingsTab({ agent }: { agent: TeamAgent }) {
const [draft, setDraft] = useState<AgentSetting[]>(agent.settings)
const [errors, setErrors] = useState<Record<string, string>>({})
const { mutate, isPending } = useSaveAgentSettings()
// Kein Zurücksetz-Effekt nötig: `Personalverwaltung` gibt diesem Reiter die
// Mitarbeiter-ID als `key` mit, der Reiter wird beim Wechsel also ohnehin neu
// aufgebaut. Ein Hintergrundabgleich verwirft damit keinen offenen Entwurf.
const schema = useMemo(() => buildSettingsSchema(agent.settings), [agent.settings])
const groups = useMemo(() => Object.values(AgentSettingGroup)
.map((group) => ({ group, items: draft.filter((setting) => setting.group === group) }))
.filter((entry) => entry.items.length > 0), [draft])
const dirty = useMemo(() => {
const saved = new Map(agent.settings.map((setting) => [setting.id, setting.value]))
return draft.some((setting) => !isSameValue(setting.value, saved.get(setting.id)))
}, [draft, agent.settings])
const errorCount = Object.keys(errors).length
const barText = `Nicht gespeicherte Änderungen${errorCount > 0 ? `${errorCount} Feld(er) prüfen` : ''}`
const handleChange = useCallback((id: string, value: SettingValue) => {
setDraft((prev) => prev.map((setting) => (setting.id === id ? { ...setting, value } : setting)))
setErrors((prev) => {
if (!prev[id]) return prev
const next = { ...prev }; delete next[id]; return next
})
}, [])
const handleReset = useCallback(() => {
setDraft(agent.settings)
setErrors({})
}, [agent.settings])
const handleSave = useCallback(() => {
const values: Record<string, SettingValue> = {}
for (const setting of draft) values[setting.id] = setting.value
const parsed = schema.safeParse(values)
if (!parsed.success) {
const found: Record<string, string> = {}
for (const { path, message } of parsed.error.issues) {
if (typeof path[0] === 'string' && !found[path[0]]) found[path[0]] = message
}
setErrors(found)
return
}
setErrors({})
mutate({ id: agent.id, settings: draft })
}, [draft, schema, mutate, agent.id])
return (
<Box>
{groups.map((entry) => (
<Box key={entry.group} sx={{ mb: 2, p: 2, borderRadius: 2, bgcolor: DS_BG.surface,
border: `1px solid ${DS_BORDER.default}`, boxShadow: DS_SHADOW.card }}>
<Typography component="h3" sx={GROUP_TITLE_SX}>
{AGENT_SETTING_GROUP_LABELS[entry.group] ?? entry.group}
</Typography>
{entry.items.map((setting) => (
<SettingRow key={setting.id} setting={setting} error={errors[setting.id]} onChange={handleChange} />
))}
</Box>
))}
{(dirty || errorCount > 0) && (
<Box role="status" sx={{ position: 'sticky', bottom: 0, display: 'flex', alignItems: 'center', gap: 1.5,
flexWrap: 'wrap', p: 1.5, borderRadius: 2, bgcolor: DS_SURFACE.warning.bg,
border: `1px solid ${DS_SURFACE.warning.border}`, boxShadow: DS_SHADOW.panel }}>
<AlertTriangle size={16} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark, fontWeight: 600 }}>{barText}</Typography>
<Box sx={{ display: 'flex', gap: 1, ml: 'auto' }}>
<Button variant="outlined" size="small" onClick={handleReset} disabled={isPending} sx={ACTION_SX}>
Verwerfen
</Button>
<Button variant="contained" size="small" onClick={handleSave} disabled={isPending || errorCount > 0}
sx={ACTION_SX}>
{isPending ? 'Wird gespeichert …' : 'Änderungen speichern'}
</Button>
</Box>
</Box>
)}
</Box>
)
}
+222
View File
@@ -0,0 +1,222 @@
/**
* Property On — Reiter «Systeme» im Personalblatt (§10).
*
* Reine Transparenz: welche Fachsysteme und Datenquellen ein digitaler
* Mitarbeiter verwenden darf. Keine Aktionen — Zugänge werden auf der Seite
* «Kanäle & Systeme» eingerichtet, nicht hier.
*
* Schreibrechte sind der einzige wirklich folgenreiche Zustand auf diesem
* Reiter (§10.4): sie verändern Kundendaten in der Bewirtschaftung. Sie stehen
* deshalb in einer eigenen Gruppe und tragen zusätzlich zur hervorgehobenen
* Fläche ein Icon UND einen Klartextsatz — Farbe allein ist kein zulässiger
* Statusträger (§2.3, §18).
*/
import { memo, useMemo } from 'react'
import { Box, Typography } from '@mui/material'
import { Eye, KeyRound, PenLine, ShieldCheck } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { AgentAccessLevel } from '../../domain/teamAgent'
import type { AgentSystem, TeamAgent } from '../../domain/teamAgent'
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
import { DetailSectionTitle } from './WorkItemDetailSections'
import { AGENT_SYSTEM_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
// ── Darstellung je Zugriffstyp ────────────────────────────────────────────────
interface AccessAppearance {
bg: string
border: string
text: string
icon: LucideIcon
/** Klartext neben dem Icon, damit der Zugriff nicht nur über die Farbe lesbar ist. */
marker: string
}
const ACCESS_APPEARANCE: Record<AgentAccessLevel, AccessAppearance> = {
[AgentAccessLevel.READ]: {
bg: DS_BG.surface,
border: DS_BORDER.default,
text: DS_TEXT.secondary,
icon: Eye,
marker: 'Liest nur — verändert in diesem System nichts',
},
[AgentAccessLevel.WRITE]: {
bg: DS_SURFACE.warning.bg,
border: DS_SURFACE.warning.border,
text: DS_TEXT.warningDark,
icon: KeyRound,
marker: 'Schreibzugriff — legt in diesem System Daten ab',
},
[AgentAccessLevel.READ_WRITE]: {
bg: DS_SURFACE.info.bg,
border: DS_SURFACE.info.border,
text: DS_TEXT.infoDark,
icon: PenLine,
marker: 'Lese- und Schreibzugriff — verändert bestehende Daten',
},
}
const GROUP_TITLE_READ = 'Lesende Systemzugänge'
const GROUP_TITLE_WRITE = 'Schreibende Systemzugänge'
function systemCount(count: number): string {
return count === 1 ? '1 Fachsystem' : `${count} Fachsysteme`
}
// ── Eine Systemzeile ──────────────────────────────────────────────────────────
/**
* `React.memo`, weil das Personalblatt bis zu einem Dutzend Systemzeilen führt
* und jeder Reiterwechsel sonst die gesamte Liste neu zeichnen würde (§10.2).
*/
const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) {
const look = ACCESS_APPEARANCE[system.access]
const AccessIcon = look.icon
return (
<Box
component="li"
sx={{
border: `1px solid ${look.border}`,
borderRadius: 2,
bgcolor: look.bg,
p: 1.75,
display: 'grid',
gap: 0.875,
}}
>
{/* Kopfzeile: System, Zugriffstyp, Verbindungsstatus */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} />
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{AGENT_SYSTEM_LABELS[system.type] ?? system.type}
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto' }}>
<AgentAccessBadge access={system.access} />
<AgentConnectionStatusBadge status={system.status} />
</Box>
</Box>
{/* Zugriffstyp im Klartext — nie nur farblich */}
<Typography variant="body2" sx={{ color: look.text, fontWeight: 600 }}>
{look.marker}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{system.usage}
</Typography>
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */}
<Box
sx={{
display: 'flex',
gap: 0.75,
alignItems: 'flex-start',
p: 1,
borderRadius: 1.5,
bgcolor: DS_BG.surface,
border: `1px solid ${DS_BORDER.default}`,
}}
>
<ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
<Box component="span" sx={{ fontWeight: 700 }}>
Berechtigung:{' '}
</Box>
{system.permissionNote}
</Typography>
</Box>
</Box>
)
})
// ── Eine Gruppe ───────────────────────────────────────────────────────────────
function SystemGroup({ title, systems }: { title: string; systems: AgentSystem[] }) {
if (systems.length === 0) return null
return (
<Box component="section" aria-label={title}>
<DetailSectionTitle>{title}</DetailSectionTitle>
<Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
{systems.map((system) => (
<SystemRow key={system.id} system={system} />
))}
</Box>
</Box>
)
}
// ── Reiter ────────────────────────────────────────────────────────────────────
export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
const { reading, writing } = useMemo(() => {
const readOnly: AgentSystem[] = []
const writable: AgentSystem[] = []
for (const system of agent.systems) {
if (system.access === AgentAccessLevel.READ) readOnly.push(system)
else writable.push(system)
}
return { reading: readOnly, writing: writable }
}, [agent.systems])
const intro = useMemo(() => {
const name = `«${agent.name}»`
if (reading.length > 0 && writing.length > 0) {
return `${name} liest ${systemCount(reading.length)} und schreibt nach Freigabe in ${systemCount(writing.length)}.`
}
if (reading.length > 0) {
return `${name} liest ${systemCount(reading.length)} und schreibt in keines davon.`
}
if (writing.length > 0) {
return `${name} schreibt nach Freigabe in ${systemCount(writing.length)}; weitere Zugänge bestehen nicht.`
}
return `Für ${name} ist bisher kein Systemzugang hinterlegt.`
}, [agent.name, reading.length, writing.length])
return (
<Box sx={{ display: 'grid', gap: 2.5 }}>
{/* Einordnung — dieser Reiter zeigt den Stand, er verändert ihn nicht */}
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.subtle,
p: 1.5,
}}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{intro}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Diese Übersicht zeigt den aktuellen Stand. Eingerichtet werden Systemzugänge auf der Seite «Kanäle &
Systeme».
</Typography>
</Box>
{agent.systems.length === 0 ? (
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 2.5,
textAlign: 'center',
}}
>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
Keine Systemzugänge hinterlegt dieser Mitarbeiter arbeitet ausschliesslich mit dem, was ihm direkt
übergeben wird.
</Typography>
</Box>
) : (
<>
<SystemGroup title={GROUP_TITLE_READ} systems={reading} />
<SystemGroup title={GROUP_TITLE_WRITE} systems={writing} />
</>
)}
</Box>
)
}
+235
View File
@@ -0,0 +1,235 @@
/**
* Property On — Register «Aufgaben» des Personalblatts (§8).
*
* Die Liste zeigt jede Aufgabe des gewählten Mitarbeiters einzeln schaltbar.
* Geändert wird zuerst nur ein lokaler Entwurf: Zuschalten und Pausieren
* verändert, was ein digitaler Mitarbeiter im Alltag der Bewirtschaftung tut —
* das darf nicht beiläufig im Hintergrund passieren, sondern erst mit einer
* bewussten Freigabe über «Änderungen speichern» (§8.2).
*/
import { memo, useCallback, useMemo, useState } from 'react'
import { Box, Button, Switch, Typography } from '@mui/material'
import { Clock, Plug, RotateCcw, Save, ShieldCheck } from 'lucide-react'
import type { AgentTask, TeamAgent } from '../../domain/teamAgent'
import { AGENT_CHANNEL_LABELS, AGENT_SYSTEM_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { GenericBadge } from '../shared/GenericBadge'
import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
// ── Eine Aufgabenzeile ────────────────────────────────────────────────────────
interface TaskRowProps {
task: AgentTask
busy: boolean
onToggle: (id: string, enabled: boolean) => void
}
/**
* `React.memo` ist Pflicht: ein Mitarbeiter führt bis zu zwölf Aufgaben, und
* jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2).
*/
const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
const dependencies = useMemo(() => {
const out: string[] = []
if (task.dependsOnChannel) {
out.push(`Kanal: ${AGENT_CHANNEL_LABELS[task.dependsOnChannel] ?? task.dependsOnChannel}`)
}
if (task.dependsOnSystem) {
out.push(`Systemzugang: ${AGENT_SYSTEM_LABELS[task.dependsOnSystem] ?? task.dependsOnSystem}`)
}
return out
}, [task.dependsOnChannel, task.dependsOnSystem])
const handleChange = useCallback(
(_event: unknown, checked: boolean) => onToggle(task.id, checked),
[onToggle, task.id],
)
return (
<Box
component="li"
sx={{
display: 'flex',
gap: 1.5,
alignItems: 'flex-start',
p: 1.75,
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: task.enabled ? DS_BG.surface : DS_BG.subtle,
boxShadow: task.enabled ? DS_SHADOW.card : 'none',
}}
>
{/* Schalter mit Zustand als Text — Farbe allein trägt keinen Status (§18) */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, width: 76 }}>
<Switch
checked={task.enabled}
disabled={busy}
onChange={handleChange}
slotProps={{ input: { 'aria-label': `Aufgabe «${task.title}» aktivieren` } }}
sx={{
'& .MuiSwitch-switchBase.Mui-focusVisible + .MuiSwitch-track': {
outline: `2px solid ${DS_TEXT.brand}`,
outlineOffset: 2,
},
}}
/>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: task.enabled ? DS_TEXT.success : DS_TEXT.muted }}
>
{task.enabled ? 'Aktiv' : 'Pausiert'}
</Typography>
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{task.title}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
{task.description}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}>
<Clock size={13} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Zeitplan: {task.schedule}
</Typography>
</Box>
{dependencies.map((dependency) => (
<Box key={dependency} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.375 }}>
<Plug size={13} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Setzt voraus {dependency}
</Typography>
</Box>
))}
{task.requiresApproval && (
<Box sx={{ mt: 1 }}>
<GenericBadge
label="Freigabe erforderlich"
semanticVariant="confidenceMedium"
bold
icon={<ShieldCheck size={13} aria-hidden />}
tooltip="Das Ergebnis dieser Aufgabe wird erst nach Ihrer Freigabe wirksam."
/>
</Box>
)}
</Box>
</Box>
)
})
// ── Register «Aufgaben» ───────────────────────────────────────────────────────
export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
const [draft, setDraft] = useState<AgentTask[]>(agent.tasks)
const saveTasks = useSaveAgentTasks()
/**
* Beim Wechsel des Mitarbeiters wird der Entwurf neu aufgesetzt — sonst stünde
* der Entwurf des zuvor gewählten Mitarbeiters in einem fremden Personalblatt.
* Bewusst nur an `agent.id` gebunden: liefe der Effekt auch bei jeder neuen
* Referenz von `agent.tasks`, würde ein Hintergrundabgleich unbestätigte
* Änderungen still verwerfen.
*/
// Kein Effekt nötig: `Personalverwaltung` gibt diesem Reiter die Mitarbeiter-ID
// als `key` mit, er wird beim Wechsel des Dossiers ohnehin neu aufgebaut.
/** Ein Durchlauf für beide Ableitungen — Anzahl aktiver Aufgaben und Abweichung (§10.4). */
const { activeCount, dirty } = useMemo(() => {
const saved = new Map(agent.tasks.map((task) => [task.id, task.enabled]))
let active = 0
let changed = draft.length !== agent.tasks.length
for (const task of draft) {
if (task.enabled) active += 1
if (saved.get(task.id) !== task.enabled) changed = true
}
return { activeCount: active, dirty: changed }
}, [draft, agent.tasks])
const handleToggle = useCallback((id: string, enabled: boolean) => {
setDraft((prev) => prev.map((task) => (task.id === id ? { ...task, enabled } : task)))
}, [])
const handleReset = useCallback(() => setDraft(agent.tasks), [agent.tasks])
const handleSave = useCallback(() => {
saveTasks.mutate({ id: agent.id, tasks: draft })
}, [saveTasks, agent.id, draft])
const busy = saveTasks.isPending
const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
return (
<Box>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{activeCount} von {draft.length} Aufgaben aktiv
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
Jede Aufgabe lässt sich einzeln zuschalten oder pausieren. Änderungen werden erst mit dem
Speichern für {agent.name} wirksam.
</Typography>
{dirty && (
<Box
role="status"
aria-live="polite"
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
mt: 1.75,
p: 1.5,
borderRadius: 2,
bgcolor: DS_SURFACE.warning.bg,
border: `1px solid ${DS_SURFACE.warning.border}`,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.warningDark }}>
Nicht gespeicherte Änderungen
</Typography>
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<Save size={15} />}
onClick={handleSave}
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto', ...focusRing }}
>
Änderungen speichern
</Button>
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<RotateCcw size={15} />}
onClick={handleReset}
sx={{ textTransform: 'none', fontWeight: 600, ...focusRing }}
>
Zurücksetzen
</Button>
</Box>
)}
{draft.length === 0 ? (
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 2 }}>
Für {agent.name} sind noch keine Aufgaben hinterlegt.
</Typography>
) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, mt: 1.75, p: 0, display: 'grid', gap: 1.25 }}>
{draft.map((task) => (
<TaskRow key={task.id} task={task} busy={busy} onToggle={handleToggle} />
))}
</Box>
)}
</Box>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { memo } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { Plug, PlugZap, Unplug, Users } from 'lucide-react'
import type { AgentConnection } from '../../domain/agentConnection'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentConnectionStatus } from '../../domain/teamAgent'
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
import {
AGENT_CONNECTION_CATEGORY_LABELS,
AGENT_ERP_VENDOR_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
interface Props {
connection: AgentConnection
agents: TeamAgent[]
busy: boolean
onConfigure: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
onTest: (connectionId: string) => void
}
export const ConnectionCard = memo(function ConnectionCard({
connection,
agents,
busy,
onConfigure,
onDisconnect,
onTest,
}: Props) {
const isConnected = connection.status === AgentConnectionStatus.CONNECTED
const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP
const users = connection.usedByAgentIds
.map(id => agents.find(a => a.id === id)?.name ?? id)
.join(', ')
const grantedPermissions = connection.permissions.filter(p => p.granted)
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,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{connection.name}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.category}
{connection.vendor ? ` · ${AGENT_ERP_VENDOR_LABELS[connection.vendor]}` : ''}
</Typography>
</Box>
<AgentConnectionStatusBadge status={connection.status} />
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, flex: 1 }}>
{connection.description}
</Typography>
{connection.connectionLabel && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Verbindung: «{connection.connectionLabel}»
</Typography>
)}
<Box sx={{ display: 'grid', gap: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{connection.accountCount === 1
? '1 verbundenes Konto'
: `${connection.accountCount} verbundene Konten`}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<Users size={12} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{users || 'Noch keinem Mitarbeiter zugewiesen'}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{connection.lastSyncAt
? `Letzte Synchronisation ${formatTeamDateTime(connection.lastSyncAt)}`
: 'Noch keine Synchronisation'}
</Typography>
</Box>
{grantedPermissions.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{grantedPermissions.map((permission) => (
<AgentAccessBadge key={permission.id} access={permission.access} />
))}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', pt: 0.5 }}>
<Button
size="small"
variant={isConnected ? 'outlined' : 'contained'}
disabled={busy || isRoadmap}
startIcon={isConnected ? <Plug size={14} /> : <PlugZap size={14} />}
onClick={() => onConfigure(connection.id)}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
{isConnected ? 'Konfigurieren' : 'Verbinden'}
</Button>
{isConnected && (
<>
<Button
size="small"
disabled={busy}
onClick={() => onTest(connection.id)}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Verbindung testen
</Button>
<Button
size="small"
color="error"
disabled={busy}
startIcon={<Unplug size={14} />}
onClick={() => onDisconnect(connection.id)}
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
>
Trennen
</Button>
</>
)}
{isRoadmap && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, alignSelf: 'center' }}>
Diese Anbindung ist geplant.
</Typography>
)}
</Box>
</Box>
)
})
@@ -0,0 +1,66 @@
import { Box, Typography } from '@mui/material'
import type { AgentConnection } from '../../domain/agentConnection'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentConnectionStatusBadge } from './AgentBadges'
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
import { formatTeamRelative } from '../../lib/teamClock'
interface Props {
connections: AgentConnection[]
agents: TeamAgent[]
}
/**
* Kompakte Darstellung der Verbindungen auf der Teamübersicht (§6.6).
* Dieselben fachlichen Inhalte wie auf der vollständigen Seite «Kanäle & Systeme»,
* nur ohne Aktionen — verbunden wird dort, nicht hier.
*/
export function ConnectionSummaryList({ connections, agents }: Props) {
const nameById = new Map(agents.map(a => [a.id, a.name]))
return (
<Box
sx={{
display: 'grid',
gap: 1,
gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)', xl: 'repeat(3, 1fr)' },
}}
>
{connections.map((connection) => {
const users = connection.usedByAgentIds.map(id => nameById.get(id) ?? id)
return (
<Box
key={connection.id}
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
{AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name}
</Typography>
<AgentConnectionStatusBadge status={connection.status} />
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{users.length > 0 ? `Genutzt von ${users.join(', ')}` : 'Noch keinem Mitarbeiter zugewiesen'}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{connection.lastSyncAt
? `Zuletzt abgeglichen ${formatTeamRelative(connection.lastSyncAt)}`
: 'Noch kein Abgleich'}
</Typography>
</Box>
)
})}
</Box>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { useMemo, useState } from 'react'
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Step,
StepLabel as MuiStepLabel,
Stepper,
} from '@mui/material'
import type { AgentConnection } from '../../domain/agentConnection'
import { AgentConnectionCategory, AgentErpVendor } from '../../domain/agentConnection'
import type { TeamAgent } from '../../domain/teamAgent'
import { useConnectAgentConnection } from '../../hooks/useAgentConnections'
import {
StepProvider,
StepLabel,
StepPermissions,
StepAgents,
StepSummary,
} from './ConnectionWizardSteps'
import type { WizardDraft } from './ConnectionWizardSteps'
const STEPS = ['Anbieter', 'Bezeichnung', 'Berechtigungen', 'Mitarbeiter', 'Zusammenfassung'] as const
interface Props {
connection: AgentConnection | null
agents: TeamAgent[]
onClose: () => void
}
/**
* Mehrstufiger Verbindungsassistent (§13.5).
*
* Der letzte Schritt der Spezifikation — «simuliert verbinden» — ist bewusst
* kein eigener Stepper-Schritt, sondern die Aktion des Zusammenfassungsschritts.
* Ein Schritt, der nur auf einen Ladebalken wartet, wäre ein leerer Klick.
*/
export function ConnectionWizard({ connection, agents, onClose }: Props) {
if (!connection) return null
// `key` auf die Verbindungs-ID: der Assistent startet für jede Verbindung frisch.
// Das ersetzt einen Zurücksetz-Effekt und hält den Zustand aus den Props ableitbar.
return (
<ConnectionWizardDialog
key={connection.id}
connection={connection}
agents={agents}
onClose={onClose}
/>
)
}
interface DialogProps {
connection: AgentConnection
agents: TeamAgent[]
onClose: () => void
}
function ConnectionWizardDialog({ connection, agents, onClose }: DialogProps) {
const [step, setStep] = useState(0)
// Wer eine bestehende Anbindung konfiguriert, will nicht bei null anfangen —
// der Entwurf startet auf dem tatsächlich hinterlegten Stand.
const [draft, setDraft] = useState<WizardDraft>(() => ({
vendor: connection.vendor ?? AgentErpVendor.IMMOTOP2,
label: connection.connectionLabel ?? '',
permissionIds: connection.permissions.filter(p => p.granted).map(p => p.id),
agentIds: connection.usedByAgentIds,
}))
const connect = useConnectAgentConnection()
const isErp = connection.category === AgentConnectionCategory.ERP
const patch = (next: Partial<WizardDraft>) => setDraft(prev => ({ ...prev, ...next }))
const canAdvance = useMemo(() => {
if (step === 1) return draft.label.trim().length > 0
if (step === 2) return draft.permissionIds.length > 0
if (step === 3) return draft.agentIds.length > 0
return true
}, [step, draft])
const stepProps = { connection, agents, draft, onChange: patch }
const handleConnect = () => {
connect.mutate(
{
connectionId: connection.id,
vendor: isErp ? draft.vendor : undefined,
connectionLabel: draft.label.trim(),
permissionIds: draft.permissionIds,
agentIds: draft.agentIds,
},
{ onSuccess: onClose },
)
}
return (
<Dialog open onClose={connect.isPending ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem' }}>
{connection.name} verbinden
</DialogTitle>
<DialogContent>
<Stepper activeStep={step} alternativeLabel sx={{ mb: 3, mt: 1 }}>
{STEPS.map((label) => (
<Step key={label}>
<MuiStepLabel sx={{ '& .MuiStepLabel-label': { fontSize: '0.75rem' } }}>{label}</MuiStepLabel>
</Step>
))}
</Stepper>
<Box sx={{ minHeight: 240 }}>
{step === 0 && <StepProvider {...stepProps} isErp={!!isErp} />}
{step === 1 && <StepLabel {...stepProps} />}
{step === 2 && <StepPermissions {...stepProps} />}
{step === 3 && <StepAgents {...stepProps} />}
{step === 4 && <StepSummary {...stepProps} isErp={!!isErp} />}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button size="small" onClick={onClose} disabled={connect.isPending} sx={{ mr: 'auto' }}>
Abbrechen
</Button>
{step > 0 && (
<Button
size="small"
variant="outlined"
disabled={connect.isPending}
onClick={() => setStep(s => s - 1)}
>
Zurück
</Button>
)}
{step < STEPS.length - 1 ? (
<Button
size="small"
variant="contained"
disabled={!canAdvance}
onClick={() => setStep(s => s + 1)}
>
Weiter
</Button>
) : (
<Button
size="small"
variant="contained"
disabled={connect.isPending}
onClick={handleConnect}
>
{connect.isPending ? 'Verbindung wird angelegt …' : 'Simuliert verbinden'}
</Button>
)}
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,235 @@
/**
* Property On — Inhalte der einzelnen Schritte des Verbindungsassistenten (§13.5).
*
* Getrennt vom Assistenten-Gerüst, damit beide Dateien unter der Grössengrenze
* bleiben und die Schrittlogik nicht mit der Darstellung vermischt wird.
*
* Es werden nie echte Zugangsdaten abgefragt — kein Passwort-, Token- oder
* Schlüsselfeld. Das ist keine Vereinfachung, sondern Vorgabe (§13.5, §23).
*/
import {
Box,
Checkbox,
FormControlLabel,
MenuItem,
TextField,
Typography,
} from '@mui/material'
import { ShieldCheck } from 'lucide-react'
import type { AgentConnection, AgentErpVendor } from '../../domain/agentConnection'
import { AgentErpVendor as ErpVendor } from '../../domain/agentConnection'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentAccessBadge } from './AgentBadges'
import { AgentAvatar } from './AgentAvatar'
import {
AGENT_ERP_VENDOR_LABELS,
AGENT_CONNECTION_CATEGORY_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
export interface WizardDraft {
vendor: AgentErpVendor
label: string
permissionIds: string[]
agentIds: string[]
}
interface StepProps {
connection: AgentConnection
agents: TeamAgent[]
draft: WizardDraft
onChange: (patch: Partial<WizardDraft>) => void
}
export function StepProvider({ connection, draft, onChange, isErp }: StepProps & { isErp: boolean }) {
return (
<Box sx={{ display: 'grid', gap: 2 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{isErp
? 'Welches Bewirtschaftungssystem setzen Sie ein? Die Auswahl bestimmt, welche Felder die digitalen Mitarbeiter lesen dürfen.'
: `Sie verbinden ${AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name}.`}
</Typography>
{isErp && (
<TextField
select
fullWidth
size="small"
label="ERP-System"
value={draft.vendor}
onChange={(e) => onChange({ vendor: e.target.value as AgentErpVendor })}
>
{Object.values(ErpVendor).map((vendor) => (
<MenuItem key={vendor} value={vendor}>{AGENT_ERP_VENDOR_LABELS[vendor]}</MenuItem>
))}
</TextField>
)}
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: DS_SURFACE.info.bg,
border: `1px solid ${DS_SURFACE.info.border}`,
display: 'flex',
gap: 1,
alignItems: 'flex-start',
}}
>
<ShieldCheck size={16} color={DS_TEXT.info} aria-hidden style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.infoDark }}>
Es werden keine echten Zugangsdaten erfasst. Der gesamte Verbindungsaufbau ist eine
Frontend-Simulation.
</Typography>
</Box>
</Box>
)
}
export function StepLabel({ connection, draft, onChange }: StepProps) {
return (
<Box sx={{ display: 'grid', gap: 2 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
Geben Sie der Verbindung einen Namen, unter dem sie in der Bewirtschaftung
wiedererkennbar ist.
</Typography>
<TextField
fullWidth
autoFocus
size="small"
label="Bezeichnung der Verbindung"
placeholder={`${connection.name} Bewirtschaftung Zürich`}
value={draft.label}
onChange={(e) => onChange({ label: e.target.value })}
helperText="Zum Beispiel nach Standort oder Team benannt."
/>
</Box>
)
}
export function StepPermissions({ connection, draft, onChange }: StepProps) {
const toggle = (id: string) => {
const next = draft.permissionIds.includes(id)
? draft.permissionIds.filter(p => p !== id)
: [...draft.permissionIds, id]
onChange({ permissionIds: next })
}
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 0.5 }}>
Welche Rechte erhalten die digitalen Mitarbeiter auf dieser Verbindung?
</Typography>
{connection.permissions.map((permission) => (
<Box
key={permission.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
px: 1.5,
py: 0.75,
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 1.5,
bgcolor: DS_BG.surface,
}}
>
<FormControlLabel
control={
<Checkbox
size="small"
checked={draft.permissionIds.includes(permission.id)}
onChange={() => toggle(permission.id)}
/>
}
label={permission.label}
sx={{ m: 0, '& .MuiFormControlLabel-label': { fontSize: '0.875rem' } }}
/>
<AgentAccessBadge access={permission.access} />
</Box>
))}
</Box>
)
}
export function StepAgents({ agents, draft, onChange }: StepProps) {
const toggle = (id: string) => {
const next = draft.agentIds.includes(id)
? draft.agentIds.filter(a => a !== id)
: [...draft.agentIds, id]
onChange({ agentIds: next })
}
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 0.5 }}>
Welche digitalen Mitarbeiter dürfen diese Verbindung nutzen?
</Typography>
{agents.map((agent) => (
<Box
key={agent.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 0.5,
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 1.5,
bgcolor: DS_BG.surface,
}}
>
<Checkbox
size="small"
checked={draft.agentIds.includes(agent.id)}
onChange={() => toggle(agent.id)}
slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }}
/>
<AgentAvatar agent={agent} size="small" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
{agent.name}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>{agent.role}</Typography>
</Box>
</Box>
))}
</Box>
)
}
export function StepSummary({ connection, agents, draft, isErp }: StepProps & { isErp: boolean }) {
const permissionLabels = connection.permissions
.filter(p => draft.permissionIds.includes(p.id))
.map(p => p.label)
const agentNames = agents.filter(a => draft.agentIds.includes(a.id)).map(a => a.name)
const rows: Array<{ label: string; value: string }> = [
...(isErp ? [{ label: 'System', value: AGENT_ERP_VENDOR_LABELS[draft.vendor] }] : []),
{ label: 'Bezeichnung', value: draft.label || '—' },
{ label: 'Berechtigungen', value: permissionLabels.join(', ') || 'Keine ausgewählt' },
{ label: 'Digitale Mitarbeiter', value: agentNames.join(', ') || 'Keine zugewiesen' },
]
return (
<Box sx={{ display: 'grid', gap: 1.25 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
Prüfen Sie die Angaben. Mit «Simuliert verbinden» wird die Verbindung angelegt und im
Protokoll festgehalten.
</Typography>
{rows.map((row) => (
<Box key={row.label} sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '180px 1fr' }, gap: 1 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 600 }}>
{row.label}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>{row.value}</Typography>
</Box>
))}
</Box>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { useMemo } from 'react'
import { Box, MenuItem, Skeleton, TextField, Typography } from '@mui/material'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentDomainArea } from '../../domain/agentWorkItem'
import { AgentPeriod } from '../../domain/agentFilters'
import { useAgentKpis } from '../../hooks/useAgentWorkItems'
import { useTeamStore } from '../../stores/teamStore'
import { toKpiFilters } from './teamFilterUtils'
import { TeamSectionHeader } from './TeamSectionHeader'
import { AGENT_PERIOD_LABELS, AGENT_DOMAIN_AREA_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
interface TileProps {
label: string
definition: string
value?: number
loading: boolean
}
/**
* Bewusst zurückhaltend: klare Zahl, Bezeichnung, eine Zeile Definition.
* Keine überdimensionierten Marketing-KPIs und keine dunkelblaue Leistungsleiste
* (§4.4) — der Bewirtschafter liest hier eine Arbeitslage, keine Werbefläche.
*/
function KpiTile({ label, definition, value, loading }: TileProps) {
return (
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
boxShadow: DS_SHADOW.card,
p: 2,
minWidth: 0,
}}
>
{loading ? (
<Skeleton variant="text" width={64} height={40} />
) : (
<Typography sx={{ fontWeight: 700, fontSize: '1.75rem', lineHeight: 1.1, color: DS_TEXT.primary }}>
{value ?? 0}
</Typography>
)}
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary, mt: 0.5 }}>
{label}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mt: 0.25 }}>
{definition}
</Typography>
</Box>
)
}
interface Props {
agents: TeamAgent[]
}
export function TeamKpiSection({ agents }: Props) {
const period = useTeamStore(s => s.overviewPeriod)
const area = useTeamStore(s => s.overviewArea)
const agentId = useTeamStore(s => s.overviewAgentId)
const setPeriod = useTeamStore(s => s.setOverviewPeriod)
const setArea = useTeamStore(s => s.setOverviewArea)
const setAgentId = useTeamStore(s => s.setOverviewAgentId)
const filters = useMemo(() => toKpiFilters({ period, area, agentId }), [period, area, agentId])
const { data: kpis, isLoading } = useAgentKpis(filters)
return (
<Box component="section" sx={{ mb: 4 }}>
<TeamSectionHeader
title="Auswertung"
description="Leistung des digitalen Teams im gewählten Zeitraum."
/>
{/* Filter direkt über den Kennzahlen — sie wirken auf alle drei zugleich (§4.2) */}
<Box sx={{ display: 'flex', gap: 1.25, flexWrap: 'wrap', mb: 2 }}>
<TextField
select
size="small"
label="Zeitraum"
value={period}
onChange={(e) => setPeriod(e.target.value as AgentPeriod)}
sx={{ minWidth: 160, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
>
{Object.values(AgentPeriod).map((p) => (
<MenuItem key={p} value={p}>{AGENT_PERIOD_LABELS[p]}</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label="Bereich"
value={area}
onChange={(e) => setArea(e.target.value as AgentDomainArea | 'ALL')}
sx={{ minWidth: 180, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
>
<MenuItem value="ALL">Alle Bereiche</MenuItem>
{Object.values(AgentDomainArea).map((a) => (
<MenuItem key={a} value={a}>{AGENT_DOMAIN_AREA_LABELS[a]}</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label="Mitarbeiter"
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
sx={{ minWidth: 200, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
>
<MenuItem value="ALL">Alle Mitarbeiter</MenuItem>
{agents.map((a) => (
<MenuItem key={a.id} value={a.id}>{a.name} · {a.role}</MenuItem>
))}
</TextField>
</Box>
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
}}
>
<KpiTile
label="Erledigte Aufgaben"
definition="Selbständig abgeschlossen, ohne Zutun eines Menschen."
value={kpis?.completedTasks}
loading={isLoading}
/>
<KpiTile
label="Pendente Nachrichten"
definition="Rückfragen und Hinweise, die eine Antwort brauchen."
value={kpis?.pendingMessages}
loading={isLoading}
/>
<KpiTile
label="Pendente Freigabeanfragen"
definition="Vorgänge, die auf eine Entscheidung der Bewirtschaftung warten."
value={kpis?.pendingApprovals}
loading={isLoading}
/>
</Box>
</Box>
)
}
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from 'react'
import { Box, Typography } from '@mui/material'
import { Info } from 'lucide-react'
import { AGENT_DEMO_NOTICE } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
interface Props {
title: string
description?: string
/** Rechts im Kopf, z. B. «Demo zurücksetzen» oder ein Quicklink. */
actions?: ReactNode
/** Reiterleiste direkt unter dem Titel, innerhalb desselben Kopfbereichs. */
tabs?: ReactNode
}
/**
* Einheitlicher Seitenkopf aller Property-On-Seiten.
*
* Bewusst weiss mit dünner Trennlinie — exakt wie die bestehenden Seitenköpfe
* der Anwendung. Der in der Spezifikation erwähnte dunkelblaue Banner «Heute
* geleistet …» existiert in dieser Codebasis nicht und wird auch nicht erzeugt
* (§3.5). Der Demo-Hinweis läuft dezent in der Kopfzeile mit (§19.3), es gibt
* keine zusätzliche Property-On-Logozeile und keinen eigenen Produkt-Header.
*/
export function TeamPageHeader({ title, description, actions, tabs }: Props) {
return (
<Box
sx={{
borderBottom: `1px solid ${DS_BORDER.default}`,
bgcolor: DS_BG.surface,
px: 3,
flexShrink: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
pt: 2.5,
pb: tabs ? 1 : 2,
flexWrap: 'wrap',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography
component="h1"
sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: DS_TEXT.primary }}
>
{title}
</Typography>
{description && (
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.5, maxWidth: 720 }}>
{description}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.75 }}>
<Info size={13} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{AGENT_DEMO_NOTICE}
</Typography>
</Box>
</Box>
{actions && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>{actions}</Box>
)}
</Box>
{tabs}
</Box>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { Box, Button, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router'
import { DS_TEXT } from '../../lib/ds'
interface Props {
title: string
description?: string
/** Quicklink rechts im Bereichskopf (§6.3, §6.6). */
quickLink?: {
label: string
to: string
}
}
export function TeamSectionHeader({ title, description, quickLink }: Props) {
const navigate = useNavigate()
return (
<Box
sx={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 2,
mb: 1.5,
flexWrap: 'wrap',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography
component="h2"
sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary, lineHeight: 1.3 }}
>
{title}
</Typography>
{description && (
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
{description}
</Typography>
)}
</Box>
{quickLink && (
<Button
size="small"
endIcon={<ArrowRight size={15} />}
onClick={() => navigate(quickLink.to)}
sx={{ textTransform: 'none', fontWeight: 600, flexShrink: 0 }}
>
{quickLink.label}
</Button>
)}
</Box>
)
}
@@ -0,0 +1,209 @@
/**
* Property On — Dialoge für Zurückweisen, Anpassen und Rückfrage beantworten (§5.6).
*
* Alle drei folgen demselben Muster: klare Beschreibung der Konsequenz, Escape
* schliesst, Bestätigen ist während der Verarbeitung deaktiviert (§17.3, §17.4).
*
* Der Formularinhalt liegt jeweils in einer inneren Komponente, die nur bei
* geöffnetem Dialog gerendert wird. Dadurch startet jedes Öffnen mit frischem
* Zustand, ohne dass ein Effekt beim Öffnen `setState` aufrufen muss — genau die
* kaskadierenden Renders, vor denen die React-Regeln warnen.
*/
import { useState } from 'react'
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Typography,
} from '@mui/material'
import type { AgentEditableField } from '../../domain/agentWorkItem'
import { DS_TEXT } from '../../lib/ds'
// ── Zurückweisen ──────────────────────────────────────────────────────────────
interface RejectDialogProps {
open: boolean
title: string
busy?: boolean
onCancel: () => void
onConfirm: (reason?: string) => void
}
function RejectBody({ title, busy, onCancel, onConfirm }: Omit<RejectDialogProps, 'open'>) {
const [reason, setReason] = useState('')
return (
<>
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem' }}>Vorgang zurückweisen</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}>
«{title}» wird zurückgewiesen und nicht ausgeführt. Der Vorgang verlässt die pendenten
Anfragen und wird im Protokoll festgehalten.
</Typography>
<TextField
fullWidth
multiline
minRows={3}
size="small"
label="Begründung (optional)"
placeholder="Weshalb wird der Vorgang zurückgewiesen?"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="outlined" size="small" onClick={onCancel} disabled={busy}>
Abbrechen
</Button>
<Button
variant="contained"
color="error"
size="small"
disabled={busy}
onClick={() => onConfirm(reason.trim() || undefined)}
>
{busy ? 'Wird zurückgewiesen …' : 'Zurückweisen'}
</Button>
</DialogActions>
</>
)
}
export function RejectWorkItemDialog({ open, ...rest }: RejectDialogProps) {
return (
<Dialog open={open} onClose={rest.onCancel} maxWidth="sm" fullWidth>
{open && <RejectBody {...rest} />}
</Dialog>
)
}
// ── Anpassen ──────────────────────────────────────────────────────────────────
interface EditDialogProps {
open: boolean
title: string
fields: AgentEditableField[]
busy?: boolean
onCancel: () => void
onConfirm: (fields: AgentEditableField[]) => void
}
function EditBody({ title, fields, busy, onCancel, onConfirm }: Omit<EditDialogProps, 'open'>) {
const [draft, setDraft] = useState<AgentEditableField[]>(fields)
const dirty = draft.some((f, i) => f.value !== fields[i]?.value)
return (
<>
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem' }}>Vorgang anpassen</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}>
«{title}» wird angepasst. Der Vorgang bleibt bis zur finalen Entscheidung pendent.
</Typography>
<Box sx={{ display: 'grid', gap: 2, mt: 1 }}>
{draft.map((field, index) => (
<TextField
key={field.id}
fullWidth
size="small"
label={field.label}
helperText={field.helperText}
multiline={field.multiline}
minRows={field.multiline ? 3 : undefined}
value={field.value}
onChange={(e) => {
const next = [...draft]
next[index] = { ...next[index], value: e.target.value }
setDraft(next)
}}
/>
))}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="outlined" size="small" onClick={onCancel} disabled={busy}>
Abbrechen
</Button>
<Button
variant="contained"
size="small"
disabled={busy || !dirty}
onClick={() => onConfirm(draft)}
>
{busy ? 'Wird gespeichert …' : 'Änderungen speichern'}
</Button>
</DialogActions>
</>
)
}
export function EditWorkItemDialog({ open, ...rest }: EditDialogProps) {
return (
<Dialog open={open} onClose={rest.onCancel} maxWidth="sm" fullWidth>
{open && <EditBody {...rest} />}
</Dialog>
)
}
// ── Rückfrage beantworten ─────────────────────────────────────────────────────
interface AnswerDialogProps {
open: boolean
question: string
busy?: boolean
onCancel: () => void
onConfirm: (answer: string) => void
}
function AnswerBody({ question, busy, onCancel, onConfirm }: Omit<AnswerDialogProps, 'open'>) {
const [answer, setAnswer] = useState('')
return (
<>
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem' }}>Rückfrage beantworten</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600, mb: 0.5 }}>
{question}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}>
Die Antwort wird Teil des Ergebnisses und im Protokoll festgehalten.
</Typography>
<TextField
fullWidth
multiline
minRows={3}
size="small"
label="Antwort"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="outlined" size="small" onClick={onCancel} disabled={busy}>
Abbrechen
</Button>
<Button
variant="contained"
size="small"
disabled={busy || answer.trim() === ''}
onClick={() => onConfirm(answer.trim())}
>
{busy ? 'Wird gespeichert …' : 'Antwort senden'}
</Button>
</DialogActions>
</>
)
}
export function AnswerQueryDialog({ open, ...rest }: AnswerDialogProps) {
return (
<Dialog open={open} onClose={rest.onCancel} maxWidth="sm" fullWidth>
{open && <AnswerBody {...rest} />}
</Dialog>
)
}
+219
View File
@@ -0,0 +1,219 @@
import { useState } from 'react'
import { Box, Button } from '@mui/material'
import { Check, CheckCheck, FileSearch, MessageSquareReply, Pencil, Scale, X } from 'lucide-react'
import type { AgentWorkItem, AgentEditableField } from '../../domain/agentWorkItem'
import { AgentWorkItemAction } from '../../domain/agentWorkItem'
import { ConfirmDialog } from '../ui'
import { RejectWorkItemDialog, EditWorkItemDialog, AnswerQueryDialog } from './WorkItemActionDialogs'
import {
useApproveWorkItem,
useRejectWorkItem,
useSaveWorkItemEdit,
useAnswerWorkItemQuery,
useMarkWorkItemDone,
} from '../../hooks/useAgentWorkItems'
import { DS_BG, DS_BORDER } from '../../lib/ds'
type OpenDialog = 'none' | 'approve' | 'decide' | 'reject' | 'edit' | 'answer'
interface Props {
item: AgentWorkItem
/** Springt zu den Fundstellen — «Quelle öffnen» ohne echtes Zielsystem. */
onOpenSource: () => void
}
/**
* Aktionsleiste eines Vorgangs (§5.6).
*
* Freigabe und Entscheidung laufen bewusst über einen Bestätigungsdialog: sie
* sind irreversibel und dürfen nicht versehentlich ausgelöst werden (§17.3).
* Die 1-Klick-Bestätigung ist die eine Ausnahme — sie ist als schneller Weg
* fachlich so vorgesehen und im Katalog explizit für Reto vorgesehen.
*/
export function WorkItemActions({ item, onOpenSource }: Props) {
const [dialog, setDialog] = useState<OpenDialog>('none')
const approve = useApproveWorkItem()
const reject = useRejectWorkItem()
const saveEdit = useSaveWorkItemEdit()
const answer = useAnswerWorkItemQuery()
const markDone = useMarkWorkItemDone()
const busy =
approve.isPending || reject.isPending || saveEdit.isPending || answer.isPending || markDone.isPending
const can = (action: string) => item.availableActions.includes(action as never)
const close = () => setDialog('none')
const runApprove = () => {
approve.mutate(item.id, { onSuccess: close })
}
return (
<>
<Box
sx={{
display: 'flex',
gap: 1,
flexWrap: 'wrap',
p: 2,
borderTop: `1px solid ${DS_BORDER.default}`,
bgcolor: DS_BG.surface,
flexShrink: 0,
}}
>
{can(AgentWorkItemAction.ONE_CLICK_CONFIRM) && (
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<CheckCheck size={15} />}
onClick={runApprove}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Mit einem Klick bestätigen
</Button>
)}
{can(AgentWorkItemAction.APPROVE) && (
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<Check size={15} />}
onClick={() => setDialog('approve')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Freigeben
</Button>
)}
{can(AgentWorkItemAction.DECIDE) && (
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<Scale size={15} />}
onClick={() => setDialog('decide')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Entscheidung treffen
</Button>
)}
{can(AgentWorkItemAction.ANSWER_QUERY) && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<MessageSquareReply size={15} />}
onClick={() => setDialog('answer')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Rückfrage beantworten
</Button>
)}
{can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<Pencil size={15} />}
onClick={() => setDialog('edit')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Anpassen
</Button>
)}
{can(AgentWorkItemAction.REJECT) && (
<Button
variant="outlined"
color="error"
size="small"
disabled={busy}
startIcon={<X size={15} />}
onClick={() => setDialog('reject')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Zurückweisen
</Button>
)}
{can(AgentWorkItemAction.MARK_DONE) && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<Check size={15} />}
onClick={() => markDone.mutate(item.id)}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Als erledigt markieren
</Button>
)}
{can(AgentWorkItemAction.OPEN_SOURCE) && item.sourceReferences.length > 0 && (
<Button
size="small"
startIcon={<FileSearch size={15} />}
onClick={onOpenSource}
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
>
Quelle öffnen
</Button>
)}
</Box>
<ConfirmDialog
open={dialog === 'approve'}
title="Vorgang freigeben"
message={`«${item.title}» wird freigegeben und wechselt zu den erledigten Aufträgen. Die Freigabe wird protokolliert und lässt sich nicht rückgängig machen.`}
confirmLabel="Freigeben"
onConfirm={runApprove}
onCancel={close}
/>
<ConfirmDialog
open={dialog === 'decide'}
title="Entscheidung bestätigen"
message={
item.decisionQuestion
? `${item.decisionQuestion} Mit der Bestätigung wird der vorgeschlagene Weg freigegeben und protokolliert.`
: `«${item.title}» wird entschieden und protokolliert.`
}
confirmLabel="Bestätigen"
onConfirm={runApprove}
onCancel={close}
/>
<RejectWorkItemDialog
open={dialog === 'reject'}
title={item.title}
busy={reject.isPending}
onCancel={close}
onConfirm={(reason) => reject.mutate({ id: item.id, reason }, { onSuccess: close })}
/>
<EditWorkItemDialog
open={dialog === 'edit'}
title={item.title}
fields={item.editableFields ?? []}
busy={saveEdit.isPending}
onCancel={close}
onConfirm={(fields: AgentEditableField[]) =>
saveEdit.mutate({ id: item.id, fields }, { onSuccess: close })
}
/>
<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 })}
/>
</>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { memo } from 'react'
import { Box, Typography } from '@mui/material'
import { AlertTriangle, Building2, Radio } from 'lucide-react'
import type { AgentWorkItem } from '../../domain/agentWorkItem'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar'
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
import {
AGENT_CHANNEL_LABELS,
AGENT_WORK_ITEM_KIND_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime, formatTeamRelative } from '../../lib/teamClock'
interface Props {
item: AgentWorkItem
agent?: TeamAgent
selected: boolean
onSelect: (id: string) => void
}
/**
* Ein Eintrag im Bearbeitungsverlauf.
*
* `React.memo` ist hier nicht optional: die Liste rendert bis zu 14 Karten und
* der Drawer verändert bei jeder Auswahl den Zustand der Seite — ohne Memo
* würde jede Auswahl die gesamte Liste neu zeichnen (CLAUDE.md §10.2).
*/
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
return (
<Box
component="article"
role="button"
tabIndex={0}
aria-current={selected ? 'true' : undefined}
onClick={() => onSelect(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(item.id)
}
}}
sx={{
border: `1px solid ${selected ? DS_TEXT.brand : DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 1.75,
cursor: 'pointer',
boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card,
transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
'&:hover': { borderColor: DS_TEXT.brand },
'&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 },
}}
>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
{agent && <AgentAvatar agent={agent} size="medium" />}
<Box sx={{ minWidth: 0, flex: 1 }}>
{/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
{agent?.name ?? 'Unbekannt'}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{agent?.role}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto', whiteSpace: 'nowrap' }}>
{formatTeamDateTime(timestamp)}
</Typography>
</Box>
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.5 }}>
{item.title}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
{item.summary}
</Typography>
{/* Grund der Rückfrage — der wichtigste Satz bei pendenten Vorgängen */}
{item.requiresDecision && item.escalationReason && (
<Box
sx={{
display: 'flex',
gap: 0.75,
alignItems: 'flex-start',
mt: 1,
p: 1,
borderRadius: 1.5,
bgcolor: DS_SURFACE.warning.bg,
border: `1px solid ${DS_SURFACE.warning.border}`,
}}
>
<AlertTriangle size={14} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark }}>
{item.escalationReason}
</Typography>
</Box>
)}
{/* Fusszeile: Vorgangstyp, Objektbezug, Quellkanal, Status */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1.25 }}>
<AgentPriorityBadge priority={item.priority} />
<AgentWorkItemStatusBadge status={item.status} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{kindLabel}
</Typography>
{/* Auch ohne Objekt-ID anzeigen: Vorgänge ohne Objektbezug tragen im
Label oft die entscheidende Einordnung («Zuordnung offen — Region
Zug»). Hinge der Block allein an der ID, ginge sie verloren. */}
{(item.objectId || item.objectLabel) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
<Building2 size={12} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{[item.objectId, item.objectLabel].filter(Boolean).join(' · ')}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.375 }}>
<Radio size={12} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{channelLabel}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto', whiteSpace: 'nowrap' }}>
{formatTeamRelative(timestamp)}
</Typography>
</Box>
</Box>
</Box>
</Box>
)
})
@@ -0,0 +1,206 @@
import { useCallback, useRef } from 'react'
import { Box, Divider, Drawer, IconButton, Typography } from '@mui/material'
import { HelpCircle, X } from 'lucide-react'
import { useTeamStore } from '../../stores/teamStore'
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import { AgentAvatar } from './AgentAvatar'
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
import { WorkItemActions } from './WorkItemActions'
import {
DetailFieldList,
DetailSectionTitle,
MessageThread,
ProcessingStepList,
SourceReferenceList,
} from './WorkItemDetailSections'
import { PanelLoadingState } from '../ui'
import { AGENT_WORK_ITEM_KIND_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
/**
* Detailansicht eines Vorgangs (§5.8).
*
* Als Drawer statt eigener Route: der Nutzer arbeitet eine Liste ab und will
* nach jeder Entscheidung sofort wieder in ihr stehen — ein Seitenwechsel
* würde bei jedem Vorgang den Kontext zerstören.
*/
export function WorkItemDetailDrawer() {
const selectedId = useTeamStore(s => s.selectedWorkItemId)
const setSelectedId = useTeamStore(s => s.setSelectedWorkItemId)
const { data: item, isLoading } = useAgentWorkItem(selectedId)
const { data: agents = [] } = useTeamAgents()
const sourcesRef = useRef<HTMLDivElement | null>(null)
const agent = agents.find(a => a.id === item?.agentId)
const close = useCallback(() => setSelectedId(null), [setSelectedId])
const scrollToSources = useCallback(() => {
sourcesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, [])
return (
<Drawer
anchor="right"
open={!!selectedId}
onClose={close}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560, lg: 640 }, display: 'flex', flexDirection: 'column' } } }}
>
{/* Kopfbereich */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
p: 2,
borderBottom: `1px solid ${DS_BORDER.default}`,
flexShrink: 0,
}}
>
{agent && <AgentAvatar agent={agent} size="medium" />}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
</Typography>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, lineHeight: 1.35 }}>
{item?.title ?? 'Vorgang wird geladen'}
</Typography>
</Box>
<IconButton size="small" onClick={close} aria-label="Detailansicht schliessen">
<X size={18} />
</IconButton>
</Box>
{/* Inhalt */}
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
{isLoading && <PanelLoadingState />}
{item && (
<Box sx={{ p: 2, display: 'grid', gap: 2.5 }}>
{/* Metazeile */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<AgentPriorityBadge priority={item.priority} />
<AgentWorkItemStatusBadge status={item.status} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind}
</Typography>
{item.objectId && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
· {item.objectId}{item.objectLabel ? ` · ${item.objectLabel}` : ''}
</Typography>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto' }}>
{formatTeamDateTime(item.completedAt ?? item.createdAt)}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.summary}</Typography>
{/* Rückfrage — bei pendenten Vorgängen die wichtigste Information */}
{item.requiresDecision && (item.escalationReason || item.decisionQuestion) && (
<Box
sx={{
p: 1.75,
borderRadius: 2,
bgcolor: DS_SURFACE.warning.bg,
border: `1px solid ${DS_SURFACE.warning.border}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<HelpCircle size={15} color={DS_TEXT.warning} aria-hidden />
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', color: DS_TEXT.warningDark }}>
Warum ist ein Mensch erforderlich?
</Typography>
</Box>
{item.escalationReason && (
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark }}>
{item.escalationReason}
</Typography>
)}
{item.decisionQuestion && (
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600, mt: 1 }}>
{item.decisionQuestion}
</Typography>
)}
</Box>
)}
{item.result.length > 0 && (
<Box>
<DetailSectionTitle>{item.requiresDecision ? 'Aktueller Stand' : 'Ergebnis'}</DetailSectionTitle>
<DetailFieldList fields={item.result} />
</Box>
)}
{item.messageThread && item.messageThread.length > 0 && (
<Box>
<DetailSectionTitle>Nachrichtenverlauf</DetailSectionTitle>
<MessageThread messages={item.messageThread} />
</Box>
)}
{item.inputs.length > 0 && (
<Box>
<DetailSectionTitle>Eingabedaten</DetailSectionTitle>
<DetailFieldList fields={item.inputs} />
</Box>
)}
{item.processingSteps.length > 0 && (
<Box>
<DetailSectionTitle>Verarbeitungsschritte</DetailSectionTitle>
<ProcessingStepList steps={item.processingSteps} />
</Box>
)}
{item.sourceReferences.length > 0 && (
<Box ref={sourcesRef} sx={{ scrollMarginTop: 8 }}>
<DetailSectionTitle>Fundstellen</DetailSectionTitle>
<SourceReferenceList references={item.sourceReferences} />
</Box>
)}
{item.rejectionReason && (
<Box>
<DetailSectionTitle>Begründung der Zurückweisung</DetailSectionTitle>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
</Box>
)}
{item.history.length > 0 && (
<Box>
<DetailSectionTitle>Aktionshistorie</DetailSectionTitle>
<Box sx={{ display: 'grid', gap: 0.75 }}>
{item.history.map((entry) => (
<Box key={entry.id} sx={{ display: 'flex', gap: 1, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, minWidth: 128 }}>
{formatTeamDateTime(entry.at)}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{entry.action}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{entry.actor}
</Typography>
{entry.note && (
<>
<Divider flexItem orientation="vertical" />
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{entry.note}</Typography>
</>
)}
</Box>
))}
</Box>
</Box>
)}
</Box>
)}
</Box>
{item && item.availableActions.length > 0 && (
<WorkItemActions item={item} onOpenSource={scrollToSources} />
)}
</Drawer>
)
}
@@ -0,0 +1,197 @@
/**
* Property On — Bausteine der Vorgangs-Detailansicht (§5.8).
*
* Vier kleine, rein darstellende Komponenten. Sie liegen zusammen in einer
* Datei, weil sie ausschliesslich gemeinsam im Detail-Drawer und im
* Protokoll-Detail verwendet werden und einzeln je unter 40 Zeilen lägen.
*/
import { Box, Divider, Typography } from '@mui/material'
import { FileText, Quote } from 'lucide-react'
import type {
AgentDetailField,
AgentProcessingStep,
AgentSourceReference,
AgentMessageEntry,
} from '../../domain/agentWorkItem'
import { AGENT_CHANNEL_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT, DS_SURFACE } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
// ── Überschrift eines Detailblocks ────────────────────────────────────────────
export function DetailSectionTitle({ children }: { children: string }) {
return (
<Typography
component="h3"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
mb: 1,
}}
>
{children}
</Typography>
)
}
// ── Schlüssel-Wert-Liste ──────────────────────────────────────────────────────
export function DetailFieldList({ fields }: { fields: AgentDetailField[] }) {
if (fields.length === 0) return null
return (
<Box component="dl" sx={{ m: 0, display: 'grid', gap: 1.25 }}>
{fields.map((field) => (
<Box key={field.label} sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '180px 1fr' }, gap: { xs: 0.25, sm: 2 } }}>
<Typography component="dt" variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 600 }}>
{field.label}
</Typography>
<Typography component="dd" variant="body2" sx={{ m: 0, color: DS_TEXT.primary }}>
{Array.isArray(field.value) ? (
<Box component="ul" sx={{ m: 0, pl: 2.25 }}>
{field.value.map((v) => <li key={v}>{v}</li>)}
</Box>
) : (
field.value
)}
</Typography>
</Box>
))}
</Box>
)
}
// ── Verarbeitungsschritte ─────────────────────────────────────────────────────
export function ProcessingStepList({ steps }: { steps: AgentProcessingStep[] }) {
if (steps.length === 0) return null
return (
<Box component="ol" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1 }}>
{steps.map((step, index) => (
<Box component="li" key={step.id} sx={{ display: 'flex', gap: 1.25, alignItems: 'flex-start' }}>
<Box
aria-hidden
sx={{
width: 20,
height: 20,
borderRadius: '50%',
bgcolor: DS_BG.subtle,
color: DS_TEXT.secondary,
fontSize: '0.6875rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
mt: 0.125,
}}
>
{index + 1}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>{step.label}</Typography>
{step.detail && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>{step.detail}</Typography>
)}
</Box>
</Box>
))}
</Box>
)
}
// ── Fundstellen ───────────────────────────────────────────────────────────────
/**
* Fundstellen sind kein Beiwerk: Sinas fachliche Regel lautet, dass eine Antwort
* ohne Quelle nicht als normale Antwort dargestellt werden darf (§14.2). Zitat
* und Fundstelle stehen deshalb gleichberechtigt neben dem Ergebnis.
*/
export function SourceReferenceList({ references }: { references: AgentSourceReference[] }) {
if (references.length === 0) return null
return (
<Box sx={{ display: 'grid', gap: 1 }}>
{references.map((ref) => (
<Box
key={ref.id}
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 1.5,
p: 1.5,
bgcolor: DS_BG.subtle,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: ref.quote ? 0.75 : 0 }}>
<FileText size={14} color={DS_TEXT.secondary} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
{ref.documentName ?? ref.label}
</Typography>
{ref.locator && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>· {ref.locator}</Typography>
)}
</Box>
{ref.quote && (
<Box sx={{ display: 'flex', gap: 0.75, alignItems: 'flex-start' }}>
<Quote size={13} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontStyle: 'italic' }}>
«{ref.quote}»
</Typography>
</Box>
)}
</Box>
))}
</Box>
)
}
// ── Nachrichtenverlauf ────────────────────────────────────────────────────────
export function MessageThread({ messages }: { messages: AgentMessageEntry[] }) {
if (messages.length === 0) return null
return (
<Box sx={{ display: 'grid', gap: 1.25 }}>
{messages.map((message) => (
<Box
key={message.id}
sx={{
border: `1px solid ${DS_SURFACE.info.border}`,
bgcolor: DS_SURFACE.info.bg,
borderRadius: 1.5,
p: 1.5,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
{message.from}
{message.to && (
<Typography component="span" variant="body2" sx={{ fontWeight: 400, color: DS_TEXT.secondary }}>
{' '}an {message.to}
</Typography>
)}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, whiteSpace: 'nowrap' }}>
{AGENT_CHANNEL_LABELS[message.channel] ?? message.channel} · {formatTeamDateTime(message.at)}
</Typography>
</Box>
{message.subject && (
<>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
{message.subject}
</Typography>
<Divider sx={{ my: 0.75 }} />
</>
)}
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, whiteSpace: 'pre-line' }}>
{message.body}
</Typography>
</Box>
))}
</Box>
)
}
+235
View File
@@ -0,0 +1,235 @@
import { Box, Button, InputAdornment, MenuItem, TextField } from '@mui/material'
import { RotateCcw, Search } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentChannelType } from '../../domain/teamAgent'
import { AgentDomainArea, AgentWorkItemKind, AgentWorkItemPriority, AgentWorkItemStatus } from '../../domain/agentWorkItem'
import { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters'
import { useTeamStore } from '../../stores/teamStore'
import {
AGENT_PERIOD_LABELS,
AGENT_DOMAIN_AREA_LABELS,
AGENT_WORK_ITEM_KIND_LABELS,
AGENT_PRIORITY_LABELS,
AGENT_WORK_ITEM_STATUS_LABELS,
AGENT_CHANNEL_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
const SORT_LABELS: Record<AgentWorkItemSort, string> = {
[AgentWorkItemSort.NEWEST]: 'Neueste zuerst',
[AgentWorkItemSort.OLDEST]: 'Älteste zuerst',
[AgentWorkItemSort.PRIORITY]: 'Höchste Priorität',
[AgentWorkItemSort.AGENT]: 'Mitarbeiter',
[AgentWorkItemSort.OBJECT_ID]: 'Objekt-ID',
}
interface FilterSelectProps<T extends string> {
label: string
value: T | 'ALL'
options: readonly T[]
labels: Record<string, string>
allLabel: string
onChange: (value: T | 'ALL') => void
width?: number
}
function FilterSelect<T extends string>({
label,
value,
options,
labels,
allLabel,
onChange,
width = 168,
}: FilterSelectProps<T>) {
return (
<TextField
select
size="small"
label={label}
value={value}
onChange={(e) => onChange(e.target.value as T | 'ALL')}
sx={{ minWidth: width, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
>
<MenuItem value="ALL">{allLabel}</MenuItem>
{options.map((option) => (
<MenuItem key={option} value={option}>
{labels[option] ?? option}
</MenuItem>
))}
</TextField>
)
}
interface Props {
agents: TeamAgent[]
}
/**
* Filter- und Suchleiste des Bearbeitungsverlaufs (§5.3).
*
* Der Filterzustand liegt im `teamStore` und nicht lokal, damit er beim Wechsel
* zwischen «Erledigte Aufträge» und «Pendente Anfragen» erhalten bleibt — sonst
* verliert der Nutzer bei jedem Reiterwechsel seine Einschränkung.
*/
export function WorkItemFilterBar({ agents }: Props) {
const period = useTeamStore(s => s.historyPeriod)
const agentId = useTeamStore(s => s.historyAgentId)
const area = useTeamStore(s => s.historyArea)
const kind = useTeamStore(s => s.historyKind)
const priority = useTeamStore(s => s.historyPriority)
const channel = useTeamStore(s => s.historyChannel)
const status = useTeamStore(s => s.historyStatus)
const search = useTeamStore(s => s.historySearch)
const sort = useTeamStore(s => s.historySort)
const setPeriod = useTeamStore(s => s.setHistoryPeriod)
const setAgentId = useTeamStore(s => s.setHistoryAgentId)
const setArea = useTeamStore(s => s.setHistoryArea)
const setKind = useTeamStore(s => s.setHistoryKind)
const setPriority = useTeamStore(s => s.setHistoryPriority)
const setChannel = useTeamStore(s => s.setHistoryChannel)
const setStatus = useTeamStore(s => s.setHistoryStatus)
const setSearch = useTeamStore(s => s.setHistorySearch)
const setSort = useTeamStore(s => s.setHistorySort)
const resetFilters = useTeamStore(s => s.resetHistoryFilters)
const hasActiveFilter =
period !== AgentPeriod.ALL ||
agentId !== 'ALL' ||
area !== 'ALL' ||
kind !== 'ALL' ||
priority !== 'ALL' ||
channel !== 'ALL' ||
status !== 'ALL' ||
search.trim() !== '' ||
sort !== AgentWorkItemSort.NEWEST
const agentLabels = Object.fromEntries(agents.map(a => [a.id, `${a.name} · ${a.role}`]))
return (
<Box
sx={{
display: 'flex',
gap: 1.25,
flexWrap: 'wrap',
alignItems: 'center',
px: 3,
py: 1.5,
borderBottom: `1px solid ${DS_BORDER.default}`,
bgcolor: DS_BG.page,
}}
>
<TextField
size="small"
placeholder="Vorgänge durchsuchen"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search size={15} color={DS_TEXT.muted} />
</InputAdornment>
),
},
htmlInput: { 'aria-label': 'Vorgänge durchsuchen' },
}}
/>
<FilterSelect
label="Zeitraum"
value={period === AgentPeriod.ALL ? 'ALL' : period}
options={[AgentPeriod.TODAY, AgentPeriod.WEEK, AgentPeriod.MONTH, AgentPeriod.YEAR]}
labels={AGENT_PERIOD_LABELS}
allLabel={AGENT_PERIOD_LABELS.ALL}
onChange={(v) => setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)}
width={150}
/>
<FilterSelect
label="Mitarbeiter"
value={agentId}
options={agents.map(a => a.id)}
labels={agentLabels}
allLabel="Alle Mitarbeiter"
onChange={setAgentId}
width={190}
/>
<FilterSelect
label="Bereich"
value={area}
options={Object.values(AgentDomainArea)}
labels={AGENT_DOMAIN_AREA_LABELS}
allLabel="Alle Bereiche"
onChange={setArea}
/>
<FilterSelect
label="Vorgangstyp"
value={kind}
options={Object.values(AgentWorkItemKind)}
labels={AGENT_WORK_ITEM_KIND_LABELS}
allLabel="Alle Vorgangstypen"
onChange={setKind}
width={186}
/>
<FilterSelect
label="Priorität"
value={priority}
options={Object.values(AgentWorkItemPriority)}
labels={AGENT_PRIORITY_LABELS}
allLabel="Alle Prioritäten"
onChange={setPriority}
width={150}
/>
<FilterSelect
label="Kanal"
value={channel}
options={Object.values(AgentChannelType)}
labels={AGENT_CHANNEL_LABELS}
allLabel="Alle Kanäle"
onChange={setChannel}
width={175}
/>
<FilterSelect
label="Status"
value={status}
options={Object.values(AgentWorkItemStatus)}
labels={AGENT_WORK_ITEM_STATUS_LABELS}
allLabel="Alle Status"
onChange={setStatus}
width={150}
/>
<TextField
select
size="small"
label="Sortierung"
value={sort}
onChange={(e) => setSort(e.target.value as AgentWorkItemSort)}
sx={{ minWidth: 178, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
>
{Object.values(AgentWorkItemSort).map((option) => (
<MenuItem key={option} value={option}>{SORT_LABELS[option]}</MenuItem>
))}
</TextField>
{hasActiveFilter && (
<Button
size="small"
startIcon={<RotateCcw size={14} />}
onClick={resetFilters}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Filter zurücksetzen
</Button>
)}
</Box>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { useCallback } from 'react'
import { Box } from '@mui/material'
import { Inbox } from 'lucide-react'
import type { AgentWorkItemFilters } from '../../provider/IAgentWorkItemProvider'
import { useAgentWorkItems } from '../../hooks/useAgentWorkItems'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import { useTeamStore } from '../../stores/teamStore'
import { WorkItemCard } from './WorkItemCard'
import { CardSkeleton, EmptyState, ErrorState } from '../ui'
interface Props {
filters: AgentWorkItemFilters
emptyTitle: string
emptyDescription: string
}
export function WorkItemList({ filters, emptyTitle, emptyDescription }: Props) {
const { data: items = [], isLoading, isError, refetch } = useAgentWorkItems(filters)
const { data: agents = [] } = useTeamAgents()
const selectedId = useTeamStore(s => s.selectedWorkItemId)
const setSelectedId = useTeamStore(s => s.setSelectedWorkItemId)
// Stabile Referenz, sonst greift React.memo auf WorkItemCard nicht (CLAUDE.md §10.3).
const handleSelect = useCallback((id: string) => setSelectedId(id), [setSelectedId])
if (isLoading) {
return (
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</Box>
)
}
if (isError) {
return <ErrorState message="Vorgänge konnten nicht geladen werden." onRetry={() => refetch()} />
}
if (items.length === 0) {
return <EmptyState icon={<Inbox size={36} />} title={emptyTitle} description={emptyDescription} />
}
return (
<Box sx={{ p: 3, display: 'grid', gap: 1.5 }}>
{items.map((item) => (
<WorkItemCard
key={item.id}
item={item}
agent={agents.find(a => a.id === item.agentId)}
selected={item.id === selectedId}
onSelect={handleSelect}
/>
))}
</Box>
)
}
@@ -0,0 +1,240 @@
/**
* Property On — Badge-Familie.
*
* Kern der Prüfung ist die Zusicherung aus §18: Ein Zustand darf nie allein über
* Farbe transportiert werden. Jede Badge muss lesbaren deutschen Text ausgeben.
* Getestet wird deshalb Verhalten (sichtbarer Text), nicht Styling.
*/
import type { ReactElement } from 'react'
import { describe, it, expect, afterEach } from 'vitest'
import { screen, waitFor, within, fireEvent, cleanup } from '@testing-library/react'
import { renderWithProviders } from '../../../test/teamTestUtils'
import {
AgentStatusBadge,
AgentPriorityBadge,
AgentWorkItemStatusBadge,
AgentConnectionStatusBadge,
AgentAccessBadge,
AgentAutonomyBadge,
AgentChannelBadge,
} from '../AgentBadges'
import {
AgentStatus,
AgentConnectionStatus,
AgentAccessLevel,
AgentAutonomyLevel,
AgentChannelType,
} from '../../../domain/teamAgent'
import { AgentWorkItemStatus, AgentWorkItemPriority } from '../../../domain/agentWorkItem'
import { AGENT_CHANNEL_LABELS } from '../../../lib/constants'
/**
* `vitest.config.ts` setzt `globals` nicht, deshalb registriert
* `@testing-library/react` sein automatisches Cleanup nicht selbst. Ohne diesen
* Hook sammelt sich das DOM über alle Tests hinweg an und `screen`-Abfragen
* finden dieselbe Badge mehrfach.
*/
afterEach(cleanup)
/** Rendert isoliert und gibt den sichtbaren Text der Badge zurück. */
function visibleText(ui: ReactElement): string {
const { container, unmount } = renderWithProviders(ui)
const text = (container.textContent ?? '').trim()
unmount()
return text
}
describe('AgentStatusBadge', () => {
it('zeigt für ACTIVE «Aktiv»', () => {
renderWithProviders(<AgentStatusBadge status={AgentStatus.ACTIVE} />)
expect(screen.getByText('Aktiv')).toBeInTheDocument()
})
it('zeigt für PAUSED «Pausiert»', () => {
renderWithProviders(<AgentStatusBadge status={AgentStatus.PAUSED} />)
expect(screen.getByText('Pausiert')).toBeInTheDocument()
})
it('zeigt für BUILDING «Im Aufbau»', () => {
renderWithProviders(<AgentStatusBadge status={AgentStatus.BUILDING} />)
expect(screen.getByText('Im Aufbau')).toBeInTheDocument()
})
it('gibt in Grösse «medium» denselben Text aus wie in «small»', () => {
expect(visibleText(<AgentStatusBadge status={AgentStatus.ACTIVE} size="medium" />)).toBe(
visibleText(<AgentStatusBadge status={AgentStatus.ACTIVE} size="small" />),
)
})
})
describe('AgentPriorityBadge', () => {
it('zeigt für CRITICAL «Kritisch»', () => {
renderWithProviders(<AgentPriorityBadge priority={AgentWorkItemPriority.CRITICAL} />)
expect(screen.getByText('Kritisch')).toBeInTheDocument()
})
it('zeigt für LOW «Niedrig»', () => {
renderWithProviders(<AgentPriorityBadge priority={AgentWorkItemPriority.LOW} />)
expect(screen.getByText('Niedrig')).toBeInTheDocument()
})
})
describe('AgentWorkItemStatusBadge', () => {
it('zeigt für PENDING «Offen»', () => {
renderWithProviders(<AgentWorkItemStatusBadge status={AgentWorkItemStatus.PENDING} />)
expect(screen.getByText('Offen')).toBeInTheDocument()
})
it('zeigt für REJECTED «Zurückgewiesen»', () => {
renderWithProviders(<AgentWorkItemStatusBadge status={AgentWorkItemStatus.REJECTED} />)
expect(screen.getByText('Zurückgewiesen')).toBeInTheDocument()
})
it('unterscheidet APPROVED und REJECTED im Text, nicht nur in der Farbe', () => {
const freigegeben = visibleText(<AgentWorkItemStatusBadge status={AgentWorkItemStatus.APPROVED} />)
const zurueckgewiesen = visibleText(<AgentWorkItemStatusBadge status={AgentWorkItemStatus.REJECTED} />)
expect(freigegeben).not.toBe(zurueckgewiesen)
})
})
describe('AgentConnectionStatusBadge', () => {
it('zeigt für CONNECTED «Verbunden»', () => {
renderWithProviders(<AgentConnectionStatusBadge status={AgentConnectionStatus.CONNECTED} />)
expect(screen.getByText('Verbunden')).toBeInTheDocument()
})
it('zeigt für ROADMAP «Geplant»', () => {
renderWithProviders(<AgentConnectionStatusBadge status={AgentConnectionStatus.ROADMAP} />)
expect(screen.getByText('Geplant')).toBeInTheDocument()
})
})
describe('AgentAccessBadge', () => {
it('zeigt für READ «Lesen»', () => {
renderWithProviders(<AgentAccessBadge access={AgentAccessLevel.READ} />)
expect(screen.getByText('Lesen')).toBeInTheDocument()
})
it('zeigt für READ_WRITE «Lesen und schreiben»', () => {
renderWithProviders(<AgentAccessBadge access={AgentAccessLevel.READ_WRITE} />)
expect(screen.getByText('Lesen und schreiben')).toBeInTheDocument()
})
it('macht Schreibrechte im Text sichtbar', () => {
expect(visibleText(<AgentAccessBadge access={AgentAccessLevel.WRITE} />)).toMatch(/schreiben/i)
expect(visibleText(<AgentAccessBadge access={AgentAccessLevel.READ} />)).not.toMatch(/schreiben/i)
})
})
describe('AgentAutonomyBadge', () => {
it('zeigt den Autonomiegrad AUTONOMOUS als Text', () => {
renderWithProviders(<AgentAutonomyBadge autonomy={AgentAutonomyLevel.AUTONOMOUS} />)
expect(screen.getByText('Autonom')).toBeInTheDocument()
})
it('zeigt den Autonomiegrad APPROVAL_REQUIRED als Text', () => {
renderWithProviders(<AgentAutonomyBadge autonomy={AgentAutonomyLevel.APPROVAL_REQUIRED} />)
expect(screen.getByText('Freigabe erforderlich')).toBeInTheDocument()
})
it('zeigt den Autonomiegrad PROPOSAL_ONLY als Text', () => {
renderWithProviders(<AgentAutonomyBadge autonomy={AgentAutonomyLevel.PROPOSAL_ONLY} />)
expect(screen.getByText('Nur Vorschlag')).toBeInTheDocument()
})
it('blendet die Autonomie-Notiz beim Überfahren als Hinweis ein', async () => {
const notiz = 'Autonom beim Erinnern, Freigabe bei Eskalationen'
renderWithProviders(<AgentAutonomyBadge autonomy={AgentAutonomyLevel.AUTONOMOUS} note={notiz} />)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.mouseOver(screen.getByText('Autonom'))
await waitFor(
() => {
expect(within(screen.getByRole('tooltip')).getByText(notiz)).toBeInTheDocument()
},
{ timeout: 3000 },
)
})
it('rendert ohne Notiz weiterhin den Autonomiegrad', () => {
renderWithProviders(<AgentAutonomyBadge autonomy={AgentAutonomyLevel.AUTONOMOUS} />)
expect(screen.getByText('Autonom')).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
describe('AgentChannelBadge', () => {
it('übersetzt den Kanalschlüssel in eine Klartextbezeichnung', () => {
renderWithProviders(<AgentChannelBadge channel={AgentChannelType.EMAIL} />)
expect(screen.getByText('E-Mail')).toBeInTheDocument()
})
it('übersetzt jeden Kanal des Katalogs in Klartext', () => {
// Seit die Prop auf `AgentChannelType` typisiert ist, kann ein unbekannter
// Schlüssel gar nicht mehr hereinkommen — der frühere Test dafür ist damit
// durch den Compiler ersetzt. Bleibt zu sichern, dass kein Kanal ohne
// Bezeichnung dasteht.
for (const channel of Object.values(AgentChannelType)) {
const { unmount } = renderWithProviders(<AgentChannelBadge channel={channel} />)
expect(screen.getByText(AGENT_CHANNEL_LABELS[channel])).toBeInTheDocument()
unmount()
}
})
})
// ── §18: Status niemals nur als Farbe ────────────────────────────────────────
interface BadgeCase {
name: string
element: ReactElement
}
const alleZustaende: BadgeCase[] = [
...Object.values(AgentStatus).map(
(wert): BadgeCase => ({ name: `AgentStatusBadge/${wert}`, element: <AgentStatusBadge status={wert} /> }),
),
...Object.values(AgentWorkItemPriority).map(
(wert): BadgeCase => ({ name: `AgentPriorityBadge/${wert}`, element: <AgentPriorityBadge priority={wert} /> }),
),
...Object.values(AgentWorkItemStatus).map(
(wert): BadgeCase => ({
name: `AgentWorkItemStatusBadge/${wert}`,
element: <AgentWorkItemStatusBadge status={wert} />,
}),
),
...Object.values(AgentConnectionStatus).map(
(wert): BadgeCase => ({
name: `AgentConnectionStatusBadge/${wert}`,
element: <AgentConnectionStatusBadge status={wert} />,
}),
),
...Object.values(AgentAccessLevel).map(
(wert): BadgeCase => ({ name: `AgentAccessBadge/${wert}`, element: <AgentAccessBadge access={wert} /> }),
),
...Object.values(AgentAutonomyLevel).map(
(wert): BadgeCase => ({ name: `AgentAutonomyBadge/${wert}`, element: <AgentAutonomyBadge autonomy={wert} /> }),
),
...Object.values(AgentChannelType).map(
(wert): BadgeCase => ({ name: `AgentChannelBadge/${wert}`, element: <AgentChannelBadge channel={wert} /> }),
),
]
describe('Zustände werden als Text ausgegeben, nicht nur als Farbe', () => {
it.each(alleZustaende)('$name rendert sichtbaren Text', ({ element }) => {
expect(visibleText(element).length).toBeGreaterThan(0)
})
it.each(alleZustaende)('$name rendert keinen rohen Enum-Schlüssel', ({ element }) => {
// Ein durchgereichter Schlüssel wie «READ_WRITE» wäre ein fehlendes Label.
expect(visibleText(element)).not.toMatch(/^[A-Z][A-Z0-9_]*$/)
})
it('vergibt je Zustand einen eigenen Text innerhalb einer Badge-Familie', () => {
const texte = Object.values(AgentWorkItemStatus).map((wert) =>
visibleText(<AgentWorkItemStatusBadge status={wert} />),
)
expect(new Set(texte).size).toBe(texte.length)
})
})
@@ -0,0 +1,164 @@
/**
* 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,267 @@
/**
* Property On — Verhaltenstests der beiden schreibenden Register des
* Personalblatts: «Aufgaben» (§8) und «Einstellungen» (§11).
*
* Beide Register ändern zuerst nur einen lokalen Entwurf und verlangen eine
* bewusste Freigabe. Genau das wird hier geprüft: die Leiste «Nicht
* gespeicherte Änderungen» erscheint erst nach einer Änderung, «Zurücksetzen»
* lässt sie wieder verschwinden, und eine ungültige Zahl blockiert das
* Speichern statt es stillschweigend durchzulassen.
*
* Getestet wird ausschliesslich beobachtbares Verhalten (sichtbarer Text,
* Rollen, aria-Label) — keine Klassennamen, keine Farbwerte, keine Snapshots.
* Zeitangaben werden nicht auf konkrete Werte geprüft: die Demo-Uhr ist auf den
* 20.05.2026 verankert, relative Formulierungen sind kein Testgegenstand.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { renderWithProviders } from '../../../test/teamTestUtils'
import { AgentTasksTab } from '../AgentTasksTab'
import { AgentSettingsTab } from '../AgentSettingsTab'
import { AGENT_IDS, mockTeamAgents } from '../../../mock-data/teamAgents'
import type { AgentSetting, AgentTask, TeamAgent } from '../../../domain/teamAgent'
import { AgentSettingKind } from '../../../domain/teamAgent'
/**
* `@testing-library/react` räumt nur automatisch auf, wenn Vitest mit
* `globals: true` läuft — tut es in diesem Repo nicht. Ohne diesen Aufruf
* blieben die gerenderten Register im Dokument stehen und `screen` fände
* Elemente aus vorangegangenen Tests.
*/
afterEach(cleanup)
const DIRTY_BAR_TEXT = 'Nicht gespeicherte Änderungen'
const SAVE_BUTTON = 'Änderungen speichern'
const RESET_BUTTON = 'Zurücksetzen'
/**
* MUI v9 gibt dem `input` eines `Switch` die Rolle `switch` (nicht `checkbox`) —
* die Schalter werden deshalb über diese Rolle und ihr aria-Label gesucht.
*/
const SWITCH_ROLE = 'switch'
// ── Zugriff auf echte Mockdaten ───────────────────────────────────────────────
function agentById(id: string): TeamAgent {
const found = mockTeamAgents.find((agent) => agent.id === id)
if (!found) throw new Error(`Mitarbeiter «${id}» fehlt in mockTeamAgents.`)
return found
}
function lockedSettingOf(agent: TeamAgent): AgentSetting {
const found = agent.settings.find((setting) => setting.locked === true)
if (!found) throw new Error(`${agent.name} hat keine gesperrte Einstellung.`)
return found
}
/** Eine Zahleneinstellung mit Bereichsgrenze — Grundlage der Validierungstests. */
function boundedNumberSettingOf(agent: TeamAgent): AgentSetting {
const found = agent.settings.find(
(setting) =>
setting.kind === AgentSettingKind.NUMBER &&
(typeof setting.min === 'number' || typeof setting.max === 'number'),
)
if (!found) throw new Error(`${agent.name} hat keine begrenzte Zahleneinstellung.`)
return found
}
function taskSwitchName(task: AgentTask): string {
return `Aufgabe «${task.title}» aktivieren`
}
const sina = agentById(AGENT_IDS.SINA)
// ── Gruppe 1: Aufgaben-Aktivierung ────────────────────────────────────────────
describe('AgentTasksTab — Aufgaben zuschalten und pausieren', () => {
it('rendert jede Aufgabe des Mitarbeiters mit ihrem Titel', () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
const list = screen.getByRole('list')
expect(within(list).getAllByRole('listitem')).toHaveLength(sina.tasks.length)
for (const task of sina.tasks) {
expect(within(list).getByText(task.title)).toBeInTheDocument()
}
})
it('nennt in der Kopfzeile die Anzahl aktiver Aufgaben', () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
const activeCount = sina.tasks.filter((task) => task.enabled).length
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
`${activeCount} von ${sina.tasks.length} Aufgaben aktiv`,
)
})
it('zeigt im Ausgangszustand keine Leiste für ungespeicherte Änderungen', () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
expect(screen.queryByText(DIRTY_BAR_TEXT)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: SAVE_BUTTON })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: RESET_BUTTON })).not.toBeInTheDocument()
})
it('bietet für jede Aufgabe einen Schalter mit ihrem Zustand als Text', () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
expect(screen.getAllByRole(SWITCH_ROLE)).toHaveLength(sina.tasks.length)
for (const task of sina.tasks) {
const control = screen.getByRole(SWITCH_ROLE, { name: taskSwitchName(task) })
expect(control).toBeInTheDocument()
expect((control as HTMLInputElement).checked).toBe(task.enabled)
}
})
it('blendet nach dem Umlegen eines Schalters die Leiste ein und macht Speichern bedienbar', async () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
const firstTask = sina.tasks[0]
const control = screen.getByRole(SWITCH_ROLE, { name: taskSwitchName(firstTask) })
fireEvent.click(control)
expect(await screen.findByText(DIRTY_BAR_TEXT)).toBeInTheDocument()
expect(screen.getByRole('button', { name: SAVE_BUTTON })).toBeEnabled()
expect((control as HTMLInputElement).checked).toBe(!firstTask.enabled)
})
it('führt die umgelegte Aufgabe in der Kopfzeile nicht mehr als aktiv', async () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
const activeCount = sina.tasks.filter((task) => task.enabled).length
const activeTask = sina.tasks.find((task) => task.enabled)
if (!activeTask) throw new Error(`${sina.name} hat keine aktive Aufgabe.`)
fireEvent.click(screen.getByRole(SWITCH_ROLE, { name: taskSwitchName(activeTask) }))
await waitFor(() =>
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
`${activeCount - 1} von ${sina.tasks.length} Aufgaben aktiv`,
),
)
})
it('lässt die Leiste nach «Zurücksetzen» wieder verschwinden', async () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
const firstTask = sina.tasks[0]
const control = screen.getByRole(SWITCH_ROLE, { name: taskSwitchName(firstTask) })
fireEvent.click(control)
expect(await screen.findByText(DIRTY_BAR_TEXT)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: RESET_BUTTON }))
await waitFor(() => expect(screen.queryByText(DIRTY_BAR_TEXT)).not.toBeInTheDocument())
expect(screen.queryByRole('button', { name: SAVE_BUTTON })).not.toBeInTheDocument()
expect((control as HTMLInputElement).checked).toBe(firstTask.enabled)
})
it('sperrt die Schalter während des Speicherns und gibt sie danach wieder frei', async () => {
renderWithProviders(<AgentTasksTab agent={sina} />)
const firstTask = sina.tasks[0]
const control = screen.getByRole(SWITCH_ROLE, { name: taskSwitchName(firstTask) })
fireEvent.click(control)
const saveButton = await screen.findByRole('button', { name: SAVE_BUTTON })
fireEvent.click(saveButton)
// Mockup-Provider mit simulierter Ladezeit — nur `waitFor`, kein festes Warten.
await waitFor(() => expect(control).toBeDisabled(), { timeout: 5000 })
await waitFor(() => expect(control).toBeEnabled(), { timeout: 5000 })
})
})
// ── Gruppe 2: Einstellungen und ihre Validierung ──────────────────────────────
describe('AgentSettingsTab — Einstellungen prüfen und freigeben', () => {
it('rendert jede Einstellung des Mitarbeiters mit ihrem Label', () => {
renderWithProviders(<AgentSettingsTab agent={sina} />)
for (const setting of sina.settings) {
expect(screen.getByText(setting.label)).toBeInTheDocument()
}
})
it('zeigt im Ausgangszustand keine Leiste für ungespeicherte Änderungen', () => {
renderWithProviders(<AgentSettingsTab agent={sina} />)
expect(screen.queryByText(/Nicht gespeicherte Änderungen/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: SAVE_BUTTON })).not.toBeInTheDocument()
})
it('stellt eine gesperrte Einstellung deaktiviert dar und nennt die Begründung', () => {
renderWithProviders(<AgentSettingsTab agent={sina} />)
const locked = lockedSettingOf(sina)
expect(screen.getByText(locked.label)).toBeInTheDocument()
const control = screen.getByRole(SWITCH_ROLE, { name: locked.label })
expect(control).toBeDisabled()
expect((control as HTMLInputElement).checked).toBe(locked.value === true)
const reason = screen.getByText(/^Gesperrt —/)
expect(reason).toHaveTextContent(locked.lockedReason ?? '')
})
it('meldet eine leere Zahleneingabe und hält den Speichern-Knopf gesperrt', async () => {
renderWithProviders(<AgentSettingsTab agent={sina} />)
const numberSetting = boundedNumberSettingOf(sina)
const field = screen.getByRole('spinbutton', { name: numberSetting.label })
fireEvent.change(field, { target: { value: '' } })
fireEvent.click(await screen.findByRole('button', { name: SAVE_BUTTON }))
await waitFor(() =>
expect(screen.getByText('Bitte eine Zahl erfassen.')).toBeInTheDocument(),
)
expect(screen.getByRole('button', { name: SAVE_BUTTON })).toBeDisabled()
expect(screen.getByText(/Nicht gespeicherte Änderungen — 1 Feld\(er\) prüfen/)).toBeInTheDocument()
})
it('meldet einen Wert oberhalb der Obergrenze und hält den Speichern-Knopf gesperrt', async () => {
renderWithProviders(<AgentSettingsTab agent={sina} />)
const numberSetting = boundedNumberSettingOf(sina)
const max = numberSetting.max
if (typeof max !== 'number') throw new Error('Erwartet wird eine Obergrenze.')
const suffix = numberSetting.unit ? ` ${numberSetting.unit}` : ''
const field = screen.getByRole('spinbutton', { name: numberSetting.label })
fireEvent.change(field, { target: { value: String(max + 100) } })
fireEvent.click(await screen.findByRole('button', { name: SAVE_BUTTON }))
await waitFor(() =>
expect(screen.getByText(`Höchstens ${max}${suffix}.`)).toBeInTheDocument(),
)
expect(screen.getByRole('button', { name: SAVE_BUTTON })).toBeDisabled()
})
it('gibt den Speichern-Knopf wieder frei, sobald ein gültiger Wert erfasst ist', async () => {
renderWithProviders(<AgentSettingsTab agent={sina} />)
const numberSetting = boundedNumberSettingOf(sina)
const max = numberSetting.max
if (typeof max !== 'number') throw new Error('Erwartet wird eine Obergrenze.')
const suffix = numberSetting.unit ? ` ${numberSetting.unit}` : ''
const field = screen.getByRole('spinbutton', { name: numberSetting.label })
fireEvent.change(field, { target: { value: String(max + 100) } })
fireEvent.click(await screen.findByRole('button', { name: SAVE_BUTTON }))
await waitFor(() =>
expect(screen.getByText(`Höchstens ${max}${suffix}.`)).toBeInTheDocument(),
)
const valid = typeof numberSetting.min === 'number' ? numberSetting.min : max - 1
fireEvent.change(field, { target: { value: String(valid) } })
await waitFor(() =>
expect(screen.queryByText(`Höchstens ${max}${suffix}.`)).not.toBeInTheDocument(),
)
expect(screen.getByRole('button', { name: SAVE_BUTTON })).toBeEnabled()
})
})
@@ -0,0 +1,189 @@
/**
* Property On Verhaltenstests der Vorgangskarte im «Bearbeitungsverlauf».
*
* Gearbeitet wird ausschliesslich mit den echten Beständen aus
* `mock-data/agentWorkItems.ts` und `mock-data/teamAgents.ts`: die Karte ist der
* Einstieg in jede Entscheidung, und genau die Daten, die in der Demo sichtbar
* sind, müssen auch geprüft werden.
*
* Zeitangaben werden bewusst nicht auf konkrete Werte geprüft Property On läuft
* gegen die Demo-Uhr aus `lib/teamClock.ts` (Anker 20.05.2026), die ab
* Anwendungsstart fortschreitet. «vor 2 Stunden» wäre daher kein deterministisches
* Prüfkriterium.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import { screen, within, fireEvent, cleanup } from '@testing-library/react'
import { renderWithProviders } from '../../../test/teamTestUtils'
import { WorkItemCard } from '../WorkItemCard'
import type { AgentWorkItem } from '../../../domain/agentWorkItem'
import type { TeamAgent } from '../../../domain/teamAgent'
import { mockAgentWorkItems } from '../../../mock-data/agentWorkItems'
import { mockTeamAgents } from '../../../mock-data/teamAgents'
// `vitest.config.ts` aktiviert `globals` nicht — damit registriert
// @testing-library/react sein automatisches Cleanup nicht selbst. Ohne diesen
// Aufruf würden sich die Karten mehrerer Tests im selben `document.body` stapeln.
afterEach(cleanup)
// ── Testdaten aus den echten Beständen ────────────────────────────────────────
function workItem(id: string): AgentWorkItem {
const item = mockAgentWorkItems.find((w) => w.id === id)
if (!item) throw new Error(`Testdaten fehlen: Vorgang «${id}» existiert nicht.`)
return item
}
function agentOf(item: AgentWorkItem): TeamAgent {
const agent = mockTeamAgents.find((a) => a.id === item.agentId)
if (!agent) throw new Error(`Testdaten fehlen: Mitarbeiter «${item.agentId}» existiert nicht.`)
return agent
}
function required(value: string | undefined, what: string): string {
if (!value) throw new Error(`Testdaten fehlen: ${what}`)
return value
}
/** Erledigter Vorgang mit Objektbezug: Ferdi, Erinnerung zur Optionsfrist. */
const COMPLETED_ID = 'awi-ferdi-01'
/** Pendenter Vorgang mit Rückfragegrund: Ferdi, Eskalation zur Freigabe. */
const PENDING_ID = 'awi-ferdi-02'
function renderCard(
item: AgentWorkItem,
{ selected = false, onSelect = vi.fn() }: { selected?: boolean; onSelect?: (id: string) => void } = {},
) {
const result = renderWithProviders(
<WorkItemCard item={item} agent={agentOf(item)} selected={selected} onSelect={onSelect} />,
)
return { ...result, onSelect }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('WorkItemCard — Inhalt', () => {
it('rendert Titel, Zusammenfassung, Mitarbeitername und Rollenbezeichnung', () => {
const item = workItem(COMPLETED_ID)
const agent = agentOf(item)
renderCard(item)
const card = screen.getByRole('button')
expect(within(card).getByText(item.title)).toBeInTheDocument()
expect(within(card).getByText(item.summary)).toBeInTheDocument()
expect(within(card).getByText(agent.name)).toBeInTheDocument()
expect(within(card).getByText(agent.role)).toBeInTheDocument()
})
it('zeigt bei einem Vorgang mit Objektbezug die Objekt-ID', () => {
const item = workItem(COMPLETED_ID)
const objectId = required(item.objectId, `Vorgang «${item.id}» hat keine Objekt-ID`)
renderCard(item)
const card = screen.getByRole('button')
expect(within(card).getByText((content) => content.includes(objectId))).toBeInTheDocument()
})
it('zeigt ohne Objektbezug keine Objekt-ID', () => {
const withoutObject = mockAgentWorkItems.find((w) => !w.objectId)
if (!withoutObject) throw new Error('Testdaten fehlen: kein Vorgang ohne Objektbezug vorhanden.')
renderCard(withoutObject)
const card = screen.getByRole('button')
expect(within(card).queryByText((content) => content.startsWith('OBJ-'))).toBeNull()
})
})
describe('WorkItemCard — Grund der Rückfrage', () => {
it('zeigt bei einem pendenten Vorgang den escalationReason', () => {
const item = workItem(PENDING_ID)
const reason = required(item.escalationReason, `Vorgang «${item.id}» hat keinen Rückfragegrund`)
expect(item.requiresDecision).toBe(true)
renderCard(item)
const card = screen.getByRole('button')
expect(within(card).getByText(reason)).toBeInTheDocument()
})
it('zeigt bei einem erledigten Vorgang keinen escalationReason', () => {
const completed = workItem(COMPLETED_ID)
const pendingReason = required(workItem(PENDING_ID).escalationReason, 'Rückfragegrund des pendenten Vorgangs')
expect(completed.requiresDecision).toBe(false)
renderCard(completed)
const card = screen.getByRole('button')
expect(within(card).queryByText(pendingReason)).toBeNull()
// Der Vorgang trägt selbst keinen Rückfragegrund — die Karte erfindet keinen.
expect(completed.escalationReason).toBeUndefined()
})
it('blendet einen vorhandenen escalationReason aus, solange keine Entscheidung nötig ist', () => {
const reason = required(workItem(PENDING_ID).escalationReason, 'Rückfragegrund des pendenten Vorgangs')
const item: AgentWorkItem = { ...workItem(COMPLETED_ID), escalationReason: reason }
renderCard(item)
const card = screen.getByRole('button')
expect(within(card).queryByText(reason)).toBeNull()
})
})
describe('WorkItemCard — Auswahl', () => {
it('ruft onSelect bei einem Klick genau einmal mit der Vorgangs-ID auf', () => {
const item = workItem(PENDING_ID)
const onSelect = vi.fn()
renderCard(item, { onSelect })
fireEvent.click(screen.getByRole('button'))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(item.id)
})
it('löst onSelect mit Enter auf der fokussierten Karte aus', () => {
const item = workItem(COMPLETED_ID)
const onSelect = vi.fn()
renderCard(item, { onSelect })
const card = screen.getByRole('button')
card.focus()
expect(card).toHaveFocus()
fireEvent.keyDown(card, { key: 'Enter' })
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(item.id)
})
it('löst onSelect auch mit der Leertaste aus', () => {
const item = workItem(COMPLETED_ID)
const onSelect = vi.fn()
renderCard(item, { onSelect })
const card = screen.getByRole('button')
card.focus()
fireEvent.keyDown(card, { key: ' ' })
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(item.id)
})
it('lässt onSelect bei einer anderen Taste unberührt', () => {
const onSelect = vi.fn()
renderCard(workItem(COMPLETED_ID), { onSelect })
fireEvent.keyDown(screen.getByRole('button'), { key: 'Escape' })
expect(onSelect).not.toHaveBeenCalled()
})
it('setzt aria-current, wenn die Karte ausgewählt ist', () => {
const item = workItem(PENDING_ID)
const { rerender } = renderCard(item, { selected: true })
expect(screen.getByRole('button')).toHaveAttribute('aria-current', 'true')
rerender(<WorkItemCard item={item} agent={agentOf(item)} selected={false} onSelect={vi.fn()} />)
expect(screen.getByRole('button')).not.toHaveAttribute('aria-current', 'true')
})
})
+66
View File
@@ -0,0 +1,66 @@
// Property On — «Teamübersicht». Barrel-Export (CLAUDE.md §13).
export { AgentAvatar } from './AgentAvatar'
export type { AgentAvatarSize } from './AgentAvatar'
export {
AgentStatusBadge,
AgentPriorityBadge,
AgentWorkItemStatusBadge,
AgentConnectionStatusBadge,
AgentAccessBadge,
AgentAutonomyBadge,
AgentChannelBadge,
} from './AgentBadges'
export { TeamPageHeader } from './TeamPageHeader'
export { TeamSectionHeader } from './TeamSectionHeader'
export { TeamKpiSection } from './TeamKpiSection'
export { AgentCard } from './AgentCard'
export { ConnectionSummaryList } from './ConnectionSummaryList'
// Personalverwaltung
export { AgentListPanel } from './AgentListPanel'
export { AgentDossierHeader } from './AgentDossierHeader'
export { AgentInfoBox } from './AgentInfoBox'
export { AgentTasksTab } from './AgentTasksTab'
export { AgentChannelsTab } from './AgentChannelsTab'
export { AgentSystemsTab } from './AgentSystemsTab'
export { AgentSettingsTab } from './AgentSettingsTab'
export { AgentProtocolTab } from './AgentProtocolTab'
// Kanäle & Systeme
export { ConnectionCard } from './ConnectionCard'
export { ConnectionWizard } from './ConnectionWizard'
export { WorkItemCard } from './WorkItemCard'
export { WorkItemList } from './WorkItemList'
export { WorkItemFilterBar } from './WorkItemFilterBar'
export { WorkItemDetailDrawer } from './WorkItemDetailDrawer'
export { WorkItemActions } from './WorkItemActions'
export {
RejectWorkItemDialog,
EditWorkItemDialog,
AnswerQueryDialog,
} from './WorkItemActionDialogs'
export {
DetailSectionTitle,
DetailFieldList,
ProcessingStepList,
SourceReferenceList,
MessageThread,
} from './WorkItemDetailSections'
export { toWorkItemFilters, toKpiFilters, toProtocolFilters } from './teamFilterUtils'
export type { HistoryFilterState, OverviewFilterState } from './teamFilterUtils'
export {
AVATAR_TONE_COLOR,
AGENT_STATUS_VARIANT,
PRIORITY_VARIANT,
WORK_ITEM_STATUS_VARIANT,
CONNECTION_STATUS_VARIANT,
ACCESS_VARIANT,
PROTOCOL_STATUS_SURFACE,
PROTOCOL_STATUS_TEXT,
} from './teamTokens'
+71
View File
@@ -0,0 +1,71 @@
/**
* Property On Übersetzung des UI-Filterzustands in Provider-Filter.
*
* Der Store hält 'ALL' als Sentinel, weil ein MUI-Select einen leeren String
* schlecht verträgt. Die Providerschicht kennt dieses Sentinel nicht sie
* erwartet `undefined` für «nicht einschränken». Diese Übersetzung gehört
* genau hierher und nicht ins JSX.
*/
import type { AgentWorkItemFilters } from '../../provider/IAgentWorkItemProvider'
import type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider'
import type {
AgentDomainArea,
AgentWorkItemKind,
AgentWorkItemPriority,
AgentWorkItemStatus,
} from '../../domain/agentWorkItem'
import type { AgentChannelType } from '../../domain/teamAgent'
import type { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters'
/** Wandelt das 'ALL'-Sentinel in `undefined`. */
function value<T extends string>(v: T | 'ALL'): T | undefined {
return v === 'ALL' ? undefined : v
}
export interface HistoryFilterState {
requiresDecision: boolean
period: AgentPeriod
agentId: string | 'ALL'
area: AgentDomainArea | 'ALL'
kind: AgentWorkItemKind | 'ALL'
priority: AgentWorkItemPriority | 'ALL'
channel: AgentChannelType | 'ALL'
status: AgentWorkItemStatus | 'ALL'
search: string
sort: AgentWorkItemSort
}
export function toWorkItemFilters(state: HistoryFilterState): AgentWorkItemFilters {
return {
requiresDecision: state.requiresDecision,
period: state.period,
agentId: value(state.agentId),
area: value(state.area),
kind: value(state.kind),
priority: value(state.priority),
channel: value(state.channel),
status: value(state.status),
search: state.search.trim() || undefined,
sort: state.sort,
}
}
export interface OverviewFilterState {
period: AgentPeriod
area: AgentDomainArea | 'ALL'
agentId: string | 'ALL'
}
/** Kennzahlenfilter der Auswertung — ohne `requiresDecision`, beide Reiter zählen mit. */
export function toKpiFilters(state: OverviewFilterState): AgentWorkItemFilters {
return {
period: state.period,
area: value(state.area),
agentId: value(state.agentId),
}
}
export function toProtocolFilters(agentId: string, period: AgentPeriod): AgentProtocolFilters {
return { agentId, period }
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Property On Zuordnung fachlicher Zustände zu Design-Tokens.
*
* Kein einziger roher Hex-Wert: `npm run check:tokens` liegt bereits über dem
* Schwellwert, der laut Skript nie angehoben werden darf. Alles kommt aus
* `src/lib/ds.ts`.
*/
import { BADGE_COLORS, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import type { BadgeSemanticVariant } from '../shared/GenericBadge'
import { AgentAvatarTone, AgentStatus, AgentConnectionStatus, AgentAccessLevel } from '../../domain/teamAgent'
import { AgentWorkItemPriority, AgentWorkItemStatus } from '../../domain/agentWorkItem'
import { AgentProtocolStatus } from '../../domain/agentProtocol'
/** Avatarfarbe je digitalem Mitarbeiter — dauerhaft dieselbe, damit man sie wiedererkennt. */
export const AVATAR_TONE_COLOR: Record<AgentAvatarTone, string> = {
[AgentAvatarTone.BRAND]: BADGE_COLORS.verified,
[AgentAvatarTone.SUCCESS]: BADGE_COLORS.confidenceHigh,
[AgentAvatarTone.INFO]: BADGE_COLORS.info,
[AgentAvatarTone.SIGNAL]: BADGE_COLORS.preMarket,
[AgentAvatarTone.WARNING]: BADGE_COLORS.confidenceMedium,
[AgentAvatarTone.GOLD]: BADGE_COLORS.gold,
[AgentAvatarTone.SLATE]: BADGE_COLORS.silver,
}
export const AGENT_STATUS_VARIANT: Record<AgentStatus, BadgeSemanticVariant> = {
[AgentStatus.ACTIVE]: 'confidenceHigh',
[AgentStatus.PAUSED]: 'confidenceMedium',
[AgentStatus.BUILDING]: 'info',
}
export const PRIORITY_VARIANT: Record<AgentWorkItemPriority, BadgeSemanticVariant> = {
[AgentWorkItemPriority.CRITICAL]: 'riskHigh',
[AgentWorkItemPriority.HIGH]: 'confidenceMedium',
[AgentWorkItemPriority.MEDIUM]: 'info',
[AgentWorkItemPriority.LOW]: 'muted',
}
export const WORK_ITEM_STATUS_VARIANT: Record<AgentWorkItemStatus, BadgeSemanticVariant> = {
[AgentWorkItemStatus.PENDING]: 'confidenceMedium',
[AgentWorkItemStatus.APPROVED]: 'confidenceHigh',
[AgentWorkItemStatus.COMPLETED]: 'confidenceHigh',
[AgentWorkItemStatus.REJECTED]: 'riskHigh',
[AgentWorkItemStatus.EDITING]: 'info',
}
export const CONNECTION_STATUS_VARIANT: Record<AgentConnectionStatus, BadgeSemanticVariant> = {
[AgentConnectionStatus.CONNECTED]: 'confidenceHigh',
[AgentConnectionStatus.DISCONNECTED]: 'confidenceMedium',
[AgentConnectionStatus.ROADMAP]: 'muted',
}
/**
* Schreibrechte werden bewusst hervorgehoben: wer schreiben darf, verändert
* Kundendaten das muss in der Zeile sofort sichtbar sein (§10.4).
*/
export const ACCESS_VARIANT: Record<AgentAccessLevel, BadgeSemanticVariant> = {
[AgentAccessLevel.READ]: 'muted',
[AgentAccessLevel.WRITE]: 'confidenceMedium',
[AgentAccessLevel.READ_WRITE]: 'info',
}
export const PROTOCOL_STATUS_SURFACE: Record<AgentProtocolStatus, { bg: string; border: string }> = {
[AgentProtocolStatus.SUCCESS]: DS_SURFACE.success,
[AgentProtocolStatus.WARNING]: DS_SURFACE.warning,
[AgentProtocolStatus.ERROR]: DS_SURFACE.error,
[AgentProtocolStatus.INFO]: DS_SURFACE.info,
}
export const PROTOCOL_STATUS_TEXT: Record<AgentProtocolStatus, string> = {
[AgentProtocolStatus.SUCCESS]: DS_TEXT.success,
[AgentProtocolStatus.WARNING]: DS_TEXT.warning,
[AgentProtocolStatus.ERROR]: DS_TEXT.error,
[AgentProtocolStatus.INFO]: DS_TEXT.info,
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router'
import { Box, Tab, Tabs } from '@mui/material'
import {
TeamPageHeader,
WorkItemFilterBar,
WorkItemList,
WorkItemDetailDrawer,
toWorkItemFilters,
} from '../../components/team'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import { useTeamStore, HistoryTab } from '../../stores/teamStore'
import { ROUTES } from '../../lib/constants'
/** URL-Segment ↔ Reiter. Deep-Links auf beide Reiter müssen erhalten bleiben (§3.4). */
const TAB_SEGMENT: Record<HistoryTab, string> = {
[HistoryTab.PENDING]: 'pendente-anfragen',
[HistoryTab.DONE]: 'erledigte-auftraege',
}
function tabFromSegment(segment: string | undefined): HistoryTab {
return segment === TAB_SEGMENT[HistoryTab.DONE] ? HistoryTab.DONE : HistoryTab.PENDING
}
export default function Bearbeitungsverlauf() {
const { tab: segment } = useParams<{ tab?: string }>()
const navigate = useNavigate()
const { data: agents = [] } = useTeamAgents()
const activeTab = tabFromSegment(segment)
const setHistoryTab = useTeamStore(s => s.setHistoryTab)
const setSelectedWorkItemId = useTeamStore(s => s.setSelectedWorkItemId)
const period = useTeamStore(s => s.historyPeriod)
const agentId = useTeamStore(s => s.historyAgentId)
const area = useTeamStore(s => s.historyArea)
const kind = useTeamStore(s => s.historyKind)
const priority = useTeamStore(s => s.historyPriority)
const channel = useTeamStore(s => s.historyChannel)
const status = useTeamStore(s => s.historyStatus)
const search = useTeamStore(s => s.historySearch)
const sort = useTeamStore(s => s.historySort)
// Die URL führt, der Store folgt — sonst widersprächen sich Deep-Link und Reiter.
useEffect(() => {
setHistoryTab(activeTab)
}, [activeTab, setHistoryTab])
const filters = useMemo(
() =>
toWorkItemFilters({
requiresDecision: activeTab === HistoryTab.PENDING,
period,
agentId,
area,
kind,
priority,
channel,
status,
search,
sort,
}),
[activeTab, period, agentId, area, kind, priority, channel, status, search, sort],
)
const handleTabChange = (next: HistoryTab) => {
setSelectedWorkItemId(null)
navigate(`${ROUTES.SUPPLY.TEAM_HISTORY}/${TAB_SEGMENT[next]}`)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
title="Bearbeitungsverlauf"
description="Was die digitalen Mitarbeiter selbständig erledigt haben — und wo sie eine Entscheidung der Bewirtschaftung brauchen."
tabs={
<Tabs
value={activeTab}
onChange={(_, v: HistoryTab) => handleTabChange(v)}
sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.95rem' } }}
>
<Tab value={HistoryTab.PENDING} label="Pendente Anfragen" />
<Tab value={HistoryTab.DONE} label="Erledigte Aufträge" />
</Tabs>
}
/>
<WorkItemFilterBar agents={agents} />
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{activeTab === HistoryTab.PENDING ? (
<WorkItemList
filters={filters}
emptyTitle="Keine pendenten Anfragen"
emptyDescription="Aktuell wartet kein Vorgang auf eine Entscheidung. Sobald ein digitaler Mitarbeiter eine Freigabe braucht, erscheint er hier."
/>
) : (
<WorkItemList
filters={filters}
emptyTitle="Keine erledigten Aufträge"
emptyDescription="Für die gewählten Filter wurde noch nichts abgeschlossen. Passen Sie den Zeitraum oder die Filter an."
/>
)}
</Box>
<WorkItemDetailDrawer />
</Box>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useCallback, useState } from 'react'
import { Box, Typography } from '@mui/material'
import {
TeamPageHeader,
ConnectionCard,
ConnectionWizard,
} from '../../components/team'
import { CardSkeleton, ConfirmDialog, ErrorState } from '../../components/ui'
import { AgentConnectionStatus } from '../../domain/teamAgent'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import {
useAgentConnections,
useDisconnectAgentConnection,
useTestAgentConnection,
} from '../../hooks/useAgentConnections'
import { DS_TEXT } from '../../lib/ds'
/**
* Subreiter «Kanäle & Systeme» (§13).
*
* Grundprinzip: die digitalen Mitarbeiter arbeiten in den bereits genutzten
* Werkzeugen der Kunden. Diese Seite dient der Konfiguration, Kontrolle und
* Rechtevergabe nicht dem Ersatz dieser Werkzeuge.
*/
export default function KanaeleSysteme() {
const { data: connections = [], isLoading, isError, refetch } = useAgentConnections()
const { data: agents = [] } = useTeamAgents()
const disconnect = useDisconnectAgentConnection()
const test = useTestAgentConnection()
const [wizardId, setWizardId] = useState<string | null>(null)
const [disconnectId, setDisconnectId] = useState<string | null>(null)
const busy = disconnect.isPending || test.isPending
const handleConfigure = useCallback((id: string) => setWizardId(id), [])
const handleDisconnect = useCallback((id: string) => setDisconnectId(id), [])
const handleTest = useCallback((id: string) => test.mutate(id), [test])
const wizardConnection = connections.find(c => c.id === wizardId) ?? null
const pendingDisconnect = connections.find(c => c.id === disconnectId) ?? null
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
title="Kanäle & Systeme"
description="Alle zentralen Verbindungen der Organisation. Die digitalen Mitarbeiter arbeiten in den Werkzeugen, die Sie ohnehin einsetzen."
/>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{isError ? (
<ErrorState message="Die Verbindungen konnten nicht geladen werden." onRetry={() => refetch()} />
) : (
<>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}>
{connections.filter(c => c.status === AgentConnectionStatus.CONNECTED).length} von {connections.length}{' '}
Verbindungen sind eingerichtet.
</Typography>
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: {
xs: '1fr',
md: 'repeat(2, 1fr)',
xl: 'repeat(3, 1fr)',
},
}}
>
{isLoading
? [0, 1, 2, 3, 4, 5].map((i) => <CardSkeleton key={i} />)
: connections.map((connection) => (
<ConnectionCard
key={connection.id}
connection={connection}
agents={agents}
busy={busy}
onConfigure={handleConfigure}
onDisconnect={handleDisconnect}
onTest={handleTest}
/>
))}
</Box>
</>
)}
</Box>
<ConnectionWizard
connection={wizardConnection}
agents={agents}
onClose={() => setWizardId(null)}
/>
<ConfirmDialog
open={!!pendingDisconnect}
destructive
title="Verbindung trennen?"
message={
pendingDisconnect
? `${pendingDisconnect.name} wird getrennt. ${pendingDisconnect.usedByAgentIds.length} digitale Mitarbeiter verlieren damit den Zugang und können die betroffenen Aufgaben nicht mehr ausführen.`
: ''
}
confirmLabel="Trennen"
onConfirm={() => {
if (pendingDisconnect) disconnect.mutate(pendingDisconnect.id)
setDisconnectId(null)
}}
onCancel={() => setDisconnectId(null)}
/>
</Box>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router'
import { Box, Tab, Tabs, useMediaQuery, useTheme } from '@mui/material'
import {
TeamPageHeader,
AgentListPanel,
AgentDossierHeader,
AgentInfoBox,
AgentTasksTab,
AgentChannelsTab,
AgentSystemsTab,
AgentSettingsTab,
AgentProtocolTab,
} from '../../components/team'
import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import { ROUTES } from '../../lib/constants'
import { DS_BG, DS_BORDER } from '../../lib/ds'
/** URL-Segment je Dossier-Reiter — Deep-Links müssen erhalten bleiben (§7.4). */
const DOSSIER_TABS = [
{ segment: 'aufgaben', label: 'Aufgaben' },
{ segment: 'kanaele', label: 'Kanäle' },
{ segment: 'systeme', label: 'Systeme' },
{ segment: 'einstellungen', label: 'Einstellungen' },
{ segment: 'protokoll', label: 'Protokoll' },
] as const
type DossierSegment = typeof DOSSIER_TABS[number]['segment']
const DEFAULT_SEGMENT: DossierSegment = 'aufgaben'
function isSegment(value: string | undefined): value is DossierSegment {
return DOSSIER_TABS.some(t => t.segment === value)
}
export default function Personalverwaltung() {
const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>()
const navigate = useNavigate()
const theme = useTheme()
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
const activeSegment: DossierSegment = isSegment(tab) ? tab : DEFAULT_SEGMENT
const selectedAgent = agents.find(a => a.id === agentId) ?? null
// Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres
// Dossier wäre für den Nutzer eine Sackgasse.
useEffect(() => {
if (!agentId && agents.length > 0) {
navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}`, { replace: true })
}
}, [agentId, agents, navigate])
const selectAgent = useCallback(
(id: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${id}/${activeSegment}`),
[navigate, activeSegment],
)
const selectTab = useCallback(
(segment: DossierSegment) => {
if (agentId) navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}/${segment}`)
},
[navigate, agentId],
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
title="Personalverwaltung"
description="Personaldossier jedes digitalen Mitarbeiters: Aufgaben, Kanäle, Systemzugänge, Einstellungen und Protokoll."
/>
{isError ? (
<ErrorState message="Die Personalverwaltung konnte nicht geladen werden." onRetry={() => refetch()} />
) : (
<Box sx={{ flex: 1, display: 'flex', flexDirection: isCompact ? 'column' : 'row', overflow: 'hidden' }}>
{/* Ebene 2: Agentenliste */}
<AgentListPanel
agents={agents}
selectedId={selectedAgent?.id ?? null}
onSelect={selectAgent}
compact={isCompact}
/>
{/* Ebene 3: Personaldossier */}
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page, minWidth: 0 }}>
{isLoading && <PanelLoadingState />}
{!isLoading && !selectedAgent && (
<EmptyState
title="Kein Mitarbeiter gewählt"
description="Wählen Sie links einen digitalen Mitarbeiter, um sein Personaldossier zu öffnen."
/>
)}
{selectedAgent && (
<>
<AgentDossierHeader agent={selectedAgent} />
<AgentInfoBox profile={selectedAgent.profile} />
<Box sx={{ px: 3, mt: 2, borderBottom: `1px solid ${DS_BORDER.default}` }}>
<Tabs
value={activeSegment}
onChange={(_, v: DossierSegment) => selectTab(v)}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem' } }}
>
{DOSSIER_TABS.map((t) => (
<Tab key={t.segment} value={t.segment} label={t.label} />
))}
</Tabs>
</Box>
{/* `key` auf die Mitarbeiter-ID: beim Wechsel des Dossiers werden die
Reiter neu aufgebaut. Das ersetzt Zurücksetz-Effekte in jedem
einzelnen Reiter sonst stünde der unbestätigte Entwurf des zuvor
gewählten Mitarbeiters in einem fremden Personalblatt. */}
<Box sx={{ px: 3, py: 2.5 }}>
{activeSegment === 'aufgaben' && <AgentTasksTab key={selectedAgent.id} agent={selectedAgent} />}
{activeSegment === 'kanaele' && <AgentChannelsTab key={selectedAgent.id} agent={selectedAgent} />}
{activeSegment === 'systeme' && <AgentSystemsTab key={selectedAgent.id} agent={selectedAgent} />}
{activeSegment === 'einstellungen' && <AgentSettingsTab key={selectedAgent.id} agent={selectedAgent} />}
{activeSegment === 'protokoll' && <AgentProtocolTab key={selectedAgent.id} agent={selectedAgent} />}
</Box>
</>
)}
</Box>
</Box>
)}
</Box>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { useCallback } from 'react'
import { useNavigate } from 'react-router'
import { Box, Button } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import {
TeamPageHeader,
TeamSectionHeader,
TeamKpiSection,
AgentCard,
ConnectionSummaryList,
} from '../../components/team'
import { CardSkeleton, ErrorState } from '../../components/ui'
import { useTeamAgents } from '../../hooks/useTeamAgents'
import { useAgentConnections } from '../../hooks/useAgentConnections'
import { useResetTeamDemo } from '../../hooks/useTeamDemo'
import { ROUTES } from '../../lib/constants'
/**
* Startseite des Property-On-Bereichs.
* Verbindliche Reihenfolge: Auswertung, Kernteam, Kanäle & Systeme (§4.1).
*/
export default function Teamuebersicht() {
const navigate = useNavigate()
const { data: agents = [], isLoading, isError, refetch } = useTeamAgents()
const { data: connections = [] } = useAgentConnections()
const resetDemo = useResetTeamDemo()
const openAgent = useCallback(
(agentId: string) => navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agentId}`),
[navigate],
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader
title="Teamübersicht"
description="Sieben digitale Mitarbeiter arbeiten in den Systemen der Bewirtschaftung. Sie führen aus, Sie entscheiden."
actions={
<Button
size="small"
variant="outlined"
startIcon={<RotateCcw size={14} />}
disabled={resetDemo.isPending}
onClick={() => resetDemo.mutate()}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
{resetDemo.isPending ? 'Wird zurückgesetzt …' : 'Demo zurücksetzen'}
</Button>
}
/>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{isError ? (
<ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} />
) : (
<>
<TeamKpiSection agents={agents} />
<Box component="section" sx={{ mb: 4 }}>
<TeamSectionHeader
title="Kernteam"
description="Alle sieben digitalen Mitarbeiter mit Zuständigkeit und aktueller Kennzahl."
quickLink={{ label: 'Zur Personalverwaltung', to: ROUTES.SUPPLY.TEAM_PERSONNEL }}
/>
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
lg: 'repeat(3, 1fr)',
xl: 'repeat(4, 1fr)',
},
}}
>
{isLoading
? [0, 1, 2, 3].map((i) => <CardSkeleton key={i} />)
: agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} onOpen={openAgent} />
))}
</Box>
</Box>
<Box component="section">
<TeamSectionHeader
title="Kanäle & Systeme"
description="Womit das digitale Team heute verbunden ist."
quickLink={{ label: 'Alle Kanäle & Systeme', to: ROUTES.SUPPLY.TEAM_CONNECTIONS }}
/>
<ConnectionSummaryList connections={connections} agents={agents} />
</Box>
</>
)}
</Box>
</Box>
)
}