diff --git a/src/App.tsx b/src/App.tsx
index 914d7d7..3984720 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -30,6 +30,12 @@ const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'
const NewListing = lazy(() => import('./pages/supply/NewListing'))
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 Results = lazy(() => import('./pages/demand/Results'))
const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
@@ -64,6 +70,18 @@ function App() {
} />
} />
} />
+
+ {/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4).
+ Bewusst flache Geschwisterrouten statt : die App kennt
+ keine einzige Feature-Route mit eigenem Outlet, ein Novum hier
+ würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
{/* Demand Workspace */}
diff --git a/src/components/layout/AppShellSidebar.tsx b/src/components/layout/AppShellSidebar.tsx
index 1566b14..fafef54 100644
--- a/src/components/layout/AppShellSidebar.tsx
+++ b/src/components/layout/AppShellSidebar.tsx
@@ -1,6 +1,6 @@
import { Box, Typography, Avatar, IconButton, Tooltip } from '@mui/material'
import { ChevronLeft, ChevronRight } from 'lucide-react'
-import { NavLink } from 'react-router'
+import { NavLink, useLocation } from 'react-router'
import { WorkspaceType } from '../../domain/enums'
import { useCompareStore } from '../../stores/compareStore'
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig'
@@ -48,6 +48,7 @@ export function Sidebar({
}: SidebarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace]
const compareCount = useCompareStore(s => s.compareItems.length)
+ const { pathname } = useLocation()
const width = collapsed ? 60 : 264
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
@@ -235,12 +236,61 @@ export function Sidebar({
)
+ // 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 ? (
{navContent}
) : (
- {navContent}
+
+ {navContent}
+ {inSection && (
+
+ {subItems.map((child) => (
+
+ onClose?.()}
+ >
+ {({ isActive }) => (
+
+
+ {child.label}
+
+
+ )}
+
+
+ ))}
+
+ )}
+
)
})}
diff --git a/src/components/layout/appShellConfig.ts b/src/components/layout/appShellConfig.ts
index 135f96e..f6423f0 100644
--- a/src/components/layout/appShellConfig.ts
+++ b/src/components/layout/appShellConfig.ts
@@ -1,4 +1,5 @@
import { WorkspaceType } from '../../domain/enums'
+import { ROUTES } from '../../lib/constants'
import type { LucideIcon } from 'lucide-react'
import {
LayoutDashboard,
@@ -13,16 +14,32 @@ import {
Kanban,
BellRing,
Settings,
+ Users,
} from 'lucide-react'
// ---------------------------------------------------------------------------
// 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 {
path: string
label: string
icon: LucideIcon
+ /**
+ * Subreiter, die sichtbar bleiben, solange sich der Nutzer innerhalb des
+ * Funktionsbereichs befindet (Property On, §3.3).
+ */
+ children?: NavSubItem[]
}
export interface WorkspaceConfig {
@@ -48,6 +65,16 @@ export const WORKSPACE_CONFIG: Record = {
navItems: [
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ 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/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ 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 item of ws.navItems) {
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\/property\//.test(pathname)) return 'Objekt Detail'
if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail'
diff --git a/src/components/team/AgentAvatar.tsx b/src/components/team/AgentAvatar.tsx
new file mode 100644
index 0000000..fd1e62f
--- /dev/null
+++ b/src/components/team/AgentAvatar.tsx
@@ -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 (
+
+ {/* `alt` reicht MUI ausschliesslich an den img-Slot weiter, und ohne `src`
+ rendert Avatar gar kein
— die Beschriftung landete damit nirgends
+ im DOM. Der zugängliche Name muss deshalb direkt auf das Element. */}
+
+ {initialsOf(agent.name)}
+
+
+ {showStatus && (
+
+
+ {isActive && }
+
+
+ )}
+
+ )
+})
diff --git a/src/components/team/AgentBadges.tsx b/src/components/team/AgentBadges.tsx
new file mode 100644
index 0000000..c9742a3
--- /dev/null
+++ b/src/components/team/AgentBadges.tsx
@@ -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 (
+
+ )
+}
+
+export function AgentPriorityBadge({ priority, size = 'small' }: { priority: AgentWorkItemPriority; size?: Size }) {
+ return (
+
+ )
+}
+
+export function AgentWorkItemStatusBadge({ status, size = 'small' }: { status: AgentWorkItemStatus; size?: Size }) {
+ return (
+
+ )
+}
+
+export function AgentConnectionStatusBadge({
+ status,
+ size = 'small',
+}: {
+ status: AgentConnectionStatus
+ size?: Size
+}) {
+ return (
+
+ )
+}
+
+export function AgentAccessBadge({ access, size = 'small' }: { access: AgentAccessLevel; size?: Size }) {
+ return (
+
+ )
+}
+
+export function AgentAutonomyBadge({
+ autonomy,
+ note,
+ size = 'small',
+}: {
+ autonomy: AgentAutonomyLevel
+ note?: string
+ size?: Size
+}) {
+ return (
+
+ )
+}
+
+export function AgentChannelBadge({ channel, size = 'small' }: { channel: AgentChannelType; size?: Size }) {
+ return (
+
+ )
+}
diff --git a/src/components/team/AgentCard.tsx b/src/components/team/AgentCard.tsx
new file mode 100644
index 0000000..a4852b5
--- /dev/null
+++ b/src/components/team/AgentCard.tsx
@@ -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 (
+
+
+
+
+
+ {agent.name}
+
+
+ {agent.role}
+
+
+ Personalnummer {agent.personnelNumber}
+
+
+
+
+
+ {agent.shortDescription}
+
+
+
+
+
+
+
+ {headline && (
+
+
+ {headline.value}
+
+
+ {headline.label}
+
+
+ )}
+
+
+
+ {agent.lastRun ? `Zuletzt aktiv ${formatTeamRelative(agent.lastRun)}` : 'Noch nicht gelaufen'}
+
+ }
+ onClick={() => onOpen(agent.id)}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Personalblatt öffnen
+
+
+
+ )
+})
diff --git a/src/components/team/AgentChannelsTab.tsx b/src/components/team/AgentChannelsTab.tsx
new file mode 100644
index 0000000..a06ac01
--- /dev/null
+++ b/src/components/team/AgentChannelsTab.tsx
@@ -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.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.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 (
+
+
+ {label}
+ {channel.optional && (
+ «optional»
+ )}
+
+
+
+
+ {AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction}
+
+
+
+
+ {channel.description}
+
+
+ onToggle(channel.id, checked)}
+ slotProps={{ input: { 'aria-label': `Kanal ${label} aktivieren` } }}
+ />
+
+ {channel.enabled ? 'Aktiv' : 'Inaktiv'}
+
+ }
+ aria-label={`Konfiguration für ${label} öffnen`}
+ onClick={() => onConfigure(channel.id)}
+ sx={{ ml: 'auto', ...FOCUS_RING }}
+ >
+ Konfiguration öffnen
+
+
+
+ )
+})
+
+// ── 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(() => ({
+ 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(null)
+
+ const label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type
+
+ const runTest = useCallback(async () => {
+ setTesting(true)
+ setResult(null)
+ await new Promise((resolve) => { window.setTimeout(resolve, 600) })
+ setResult(TEST_RESULT_BY_STATUS[channel.status])
+ setTesting(false)
+ }, [channel.status])
+
+ return (
+ <>
+ Kanal «{label}» konfigurieren
+
+
+ Frontend-Simulation zur Veranschaulichung: Die Angaben werden nicht gespeichert und es werden
+ keine echten Zugangsdaten erfasst.
+
+
+
+ setDraft({ ...draft, displayName: e.target.value })} />
+ setDraft({ ...draft, senderAddress: e.target.value })} />
+ setDraft({ ...draft, inboxAddress: e.target.value })} />
+ setDraft({ ...draft, recipients: e.target.value })} />
+
+ setDraft({ ...draft, enabled: checked })} />}
+ label={`Kanal aktiv — ${draft.enabled ? 'nimmt Aufträge entgegen' : 'nimmt derzeit nichts entgegen'}`}
+ />
+ setDraft({ ...draft, autoReply: checked })} />}
+ label={`Antwortfreigabe — ${draft.autoReply ? 'Antworten gehen direkt raus' : 'Antworten benötigen eine Freigabe'}`}
+ />
+
+
+ {result && {result.message}}
+
+
+
+
+
+ >
+ )
+}
+
+function ChannelConfigDialog({ channel, onClose }: { channel: AgentChannel | null; onClose: () => void }) {
+ return (
+
+ )
+}
+
+// ── Registerkarte ─────────────────────────────────────────────────────────────
+
+export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
+ const { mutate: saveChannels } = useSaveAgentChannels()
+ const [openChannelId, setOpenChannelId] = useState(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 (
+
+
+ Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus.
+
+
+ {summary.active} von {channels.length} Kanälen aktiv · {summary.connected} verbunden
+
+
+ {channels.length === 0 ? (
+
+ Für {agent.name} ist noch kein Kanal hinterlegt.
+
+ ) : (
+
+ {channels.map((channel) => (
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/team/AgentDossierHeader.tsx b/src/components/team/AgentDossierHeader.tsx
new file mode 100644
index 0000000..12f58d7
--- /dev/null
+++ b/src/components/team/AgentDossierHeader.tsx
@@ -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 (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+ {agent.name}
+
+
+ {agent.role}
+
+
+
+
+ {agent.email}
+
+
+
+
+
+
+
+
+ handleToggle(e.target.checked)}
+ slotProps={{ input: { 'aria-label': `${agent.name} aktiv` } }}
+ />
+ }
+ label="Agent aktiv"
+ sx={{ mr: 0, '& .MuiFormControlLabel-label': { fontWeight: 600, fontSize: '0.875rem' } }}
+ />
+
+
+ {/* Stammdaten */}
+
+
+
+
+
+
+
+ {/* Warnung im Personalblatt, solange pausiert (§7.5) */}
+ {!isActive && (
+
+
+
+ {agent.name} ist pausiert. Bis zur Reaktivierung werden keine Aufgaben ausgeführt und es
+ entstehen keine neuen Vorgänge.
+
+
+ )}
+
+ {
+ setActive.mutate({ id: agent.id, active: false })
+ setConfirmPause(false)
+ }}
+ onCancel={() => setConfirmPause(false)}
+ />
+
+ )
+}
diff --git a/src/components/team/AgentInfoBox.tsx b/src/components/team/AgentInfoBox.tsx
new file mode 100644
index 0000000..e445e71
--- /dev/null
+++ b/src/components/team/AgentInfoBox.tsx
@@ -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 (
+
+
+ {icon}
+
+ {title}
+
+
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+
+ {profile.purpose}
+
+
+
+
+ }
+ title="Input"
+ items={profile.input}
+ />
+ }
+ title="Kernablauf"
+ items={profile.coreFlow}
+ />
+ }
+ title="Output"
+ items={profile.output}
+ />
+
+
+ )
+}
diff --git a/src/components/team/AgentListPanel.tsx b/src/components/team/AgentListPanel.tsx
new file mode 100644
index 0000000..fc95869
--- /dev/null
+++ b/src/components/team/AgentListPanel.tsx
@@ -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 (
+ 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 },
+ }}
+ >
+
+
+
+ {agent.name}
+
+
+ {agent.role}
+
+
+
+
+ )
+})
+
+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 (
+
+ onSelect(e.target.value)}
+ >
+ {agents.map((agent) => (
+
+ ))}
+
+
+ )
+ }
+
+ return (
+
+
+ Kernteam
+
+
+ {agents.map((agent) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/team/AgentProtocolTab.tsx b/src/components/team/AgentProtocolTab.tsx
new file mode 100644
index 0000000..fae15a8
--- /dev/null
+++ b/src/components/team/AgentProtocolTab.tsx
@@ -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.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 = {
+ label: string; value: T | 'ALL'; options: readonly T[]
+ labels: Record; allLabel: string; onChange: (value: T | 'ALL') => void
+}
+
+function FilterSelect({ label, value, options, labels, allLabel, onChange }: FilterSelectProps) {
+ return (
+ onChange(e.target.value as T | 'ALL')} sx={SELECT_SX}>
+
+ {options.map((option) => )}
+
+ )
+}
+
+function MetaItem({ icon, text }: { icon: ReactNode; text: string }) {
+ return (
+
+ {icon}
+ {text}
+
+ )
+}
+
+function DetailBlock({ title, children }: { title: string; children: ReactNode }) {
+ return {title}{children}
+}
+
+// ── 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 (
+
+ {/* Punkt und Verbindungslinie */}
+
+
+
+
+ {!isLast && }
+
+
+
+
+
+ {formatTeamDateTime(entry.timestamp)}
+ · {AGENT_PROTOCOL_EVENT_LABELS[entry.eventType] ?? entry.eventType}
+ {AGENT_PROTOCOL_STATUS_LABELS[entry.status] ?? entry.status}
+
+
+ {entry.title}
+ {entry.description}
+
+
+ {entry.objectId && } text={entry.objectId} />}
+ {entry.channel && } text={AGENT_CHANNEL_LABELS[entry.channel] ?? entry.channel} />}
+ } text={actor} />
+
+
+ {hasDetails && (
+ <>
+
+
+
+
+ {input && input.length > 0 && }
+ {processingSteps && processingSteps.length > 0 && }
+ {output && output.length > 0 && }
+ {sourceReferences && sourceReferences.length > 0 && }
+
+
+ >
+ )}
+
+
+
+ )
+})
+
+// ── Reiter ────────────────────────────────────────────────────────────────────
+
+export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
+ const [period, setPeriod] = useState(AgentPeriod.ALL)
+ const [eventType, setEventType] = useState('ALL')
+ const [status, setStatus] = useState('ALL')
+ const [search, setSearch] = useState('')
+ const [onlyApprovals, setOnlyApprovals] = useState(false)
+ const [expandedId, setExpandedId] = useState(null)
+
+ const filters = useMemo(() => ({
+ 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 (
+
+ {/* Filterleiste — kompakt in einer Zeile, umbrechend */}
+
+ setSearch(e.target.value)}
+ slotProps={{
+ input: { startAdornment: },
+ htmlInput: { 'aria-label': 'Protokoll durchsuchen' },
+ }}
+ />
+ setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)}
+ />
+
+
+ setOnlyApprovals(checked)} />}
+ label={Nur Freigaben}
+ sx={{ ml: 0.25, mr: 0 }}
+ />
+
+
+ {isLoading && (
+
+ {SKELETON_ROWS.map((row) => )}
+
+ )}
+
+ {isError && { void refetch() }} />}
+
+ {!isLoading && !isError && entries.length === 0 && (
+ }
+ 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 && (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/team/AgentSettingsTab.tsx b/src/components/team/AgentSettingsTab.tsx
new file mode 100644
index 0000000..f215a09
--- /dev/null
+++ b/src/components/team/AgentSettingsTab.tsx
@@ -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 = {}
+ 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) => )
+
+ let control: ReactNode
+ switch (setting.kind) {
+ case AgentSettingKind.BOOLEAN:
+ control = (
+ onChange(setting.id, e.target.checked)} />}
+ />
+ )
+ break
+ case AgentSettingKind.NUMBER:
+ control = (
+ onChange(setting.id, e.target.value.trim() === '' ? Number.NaN : Number(e.target.value))}
+ slotProps={{
+ input: setting.unit ? { endAdornment: {setting.unit} } : undefined,
+ htmlInput: { min: setting.min, max: setting.max, ...aria },
+ }} />
+ )
+ break
+ case AgentSettingKind.SELECT:
+ control = (
+ onChange(setting.id, e.target.value)}>{items}
+ )
+ break
+ case AgentSettingKind.MULTI_SELECT:
+ control = (
+ <>
+
+ {error && {error}}
+ >
+ )
+ break
+ default:
+ control = (
+ onChange(setting.id, e.target.value)} />
+ )
+ }
+
+ return (
+
+ {setting.label}
+ {setting.description && (
+ {setting.description}
+ )}
+ {control}
+ {locked && (
+
+
+
+ Gesperrt — {setting.lockedReason ?? 'Diese Einstellung ist fachlich zwingend und bleibt eingeschaltet.'}
+
+
+ )}
+
+ )
+})
+
+// ── Reiter ────────────────────────────────────────────────────────────────────
+
+export function AgentSettingsTab({ agent }: { agent: TeamAgent }) {
+ const [draft, setDraft] = useState(agent.settings)
+ const [errors, setErrors] = useState>({})
+ 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 = {}
+ for (const setting of draft) values[setting.id] = setting.value
+ const parsed = schema.safeParse(values)
+ if (!parsed.success) {
+ const found: Record = {}
+ 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 (
+
+ {groups.map((entry) => (
+
+
+ {AGENT_SETTING_GROUP_LABELS[entry.group] ?? entry.group}
+
+ {entry.items.map((setting) => (
+
+ ))}
+
+ ))}
+
+ {(dirty || errorCount > 0) && (
+
+
+ {barText}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/team/AgentSystemsTab.tsx b/src/components/team/AgentSystemsTab.tsx
new file mode 100644
index 0000000..9279ec4
--- /dev/null
+++ b/src/components/team/AgentSystemsTab.tsx
@@ -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.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 (
+
+ {/* Kopfzeile: System, Zugriffstyp, Verbindungsstatus */}
+
+
+
+ {AGENT_SYSTEM_LABELS[system.type] ?? system.type}
+
+
+
+
+
+
+
+ {/* Zugriffstyp im Klartext — nie nur farblich */}
+
+ {look.marker}
+
+
+
+ {system.usage}
+
+
+ {/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */}
+
+
+
+
+ Berechtigung:{' '}
+
+ {system.permissionNote}
+
+
+
+ )
+})
+
+// ── Eine Gruppe ───────────────────────────────────────────────────────────────
+
+function SystemGroup({ title, systems }: { title: string; systems: AgentSystem[] }) {
+ if (systems.length === 0) return null
+
+ return (
+
+ {title}
+
+ {systems.map((system) => (
+
+ ))}
+
+
+ )
+}
+
+// ── 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 (
+
+ {/* Einordnung — dieser Reiter zeigt den Stand, er verändert ihn nicht */}
+
+
+ {intro}
+
+
+ Diese Übersicht zeigt den aktuellen Stand. Eingerichtet werden Systemzugänge auf der Seite «Kanäle &
+ Systeme».
+
+
+
+ {agent.systems.length === 0 ? (
+
+
+ Keine Systemzugänge hinterlegt — dieser Mitarbeiter arbeitet ausschliesslich mit dem, was ihm direkt
+ übergeben wird.
+
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/team/AgentTasksTab.tsx b/src/components/team/AgentTasksTab.tsx
new file mode 100644
index 0000000..0c15936
--- /dev/null
+++ b/src/components/team/AgentTasksTab.tsx
@@ -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 (
+
+ {/* Schalter mit Zustand als Text — Farbe allein trägt keinen Status (§18) */}
+
+
+
+ {task.enabled ? 'Aktiv' : 'Pausiert'}
+
+
+
+
+
+ {task.title}
+
+
+
+ {task.description}
+
+
+
+
+
+ Zeitplan: {task.schedule}
+
+
+
+ {dependencies.map((dependency) => (
+
+
+
+ Setzt voraus — {dependency}
+
+
+ ))}
+
+ {task.requiresApproval && (
+
+ }
+ tooltip="Das Ergebnis dieser Aufgabe wird erst nach Ihrer Freigabe wirksam."
+ />
+
+ )}
+
+
+ )
+})
+
+// ── Register «Aufgaben» ───────────────────────────────────────────────────────
+
+export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
+ const [draft, setDraft] = useState(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 (
+
+
+ {activeCount} von {draft.length} Aufgaben aktiv
+
+
+ Jede Aufgabe lässt sich einzeln zuschalten oder pausieren. Änderungen werden erst mit dem
+ Speichern für {agent.name} wirksam.
+
+
+ {dirty && (
+
+
+ Nicht gespeicherte Änderungen
+
+ }
+ onClick={handleSave}
+ sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto', ...focusRing }}
+ >
+ Änderungen speichern
+
+ }
+ onClick={handleReset}
+ sx={{ textTransform: 'none', fontWeight: 600, ...focusRing }}
+ >
+ Zurücksetzen
+
+
+ )}
+
+ {draft.length === 0 ? (
+
+ Für {agent.name} sind noch keine Aufgaben hinterlegt.
+
+ ) : (
+
+ {draft.map((task) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/team/ConnectionCard.tsx b/src/components/team/ConnectionCard.tsx
new file mode 100644
index 0000000..d750384
--- /dev/null
+++ b/src/components/team/ConnectionCard.tsx
@@ -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 (
+
+
+
+
+ {connection.name}
+
+
+ {AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.category}
+ {connection.vendor ? ` · ${AGENT_ERP_VENDOR_LABELS[connection.vendor]}` : ''}
+
+
+
+
+
+
+ {connection.description}
+
+
+ {connection.connectionLabel && (
+
+ Verbindung: «{connection.connectionLabel}»
+
+ )}
+
+
+
+ {connection.accountCount === 1
+ ? '1 verbundenes Konto'
+ : `${connection.accountCount} verbundene Konten`}
+
+
+
+
+
+ {users || 'Noch keinem Mitarbeiter zugewiesen'}
+
+
+
+
+ {connection.lastSyncAt
+ ? `Letzte Synchronisation ${formatTeamDateTime(connection.lastSyncAt)}`
+ : 'Noch keine Synchronisation'}
+
+
+
+ {grantedPermissions.length > 0 && (
+
+ {grantedPermissions.map((permission) => (
+
+ ))}
+
+ )}
+
+
+ : }
+ onClick={() => onConfigure(connection.id)}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ {isConnected ? 'Konfigurieren' : 'Verbinden'}
+
+
+ {isConnected && (
+ <>
+
+ }
+ onClick={() => onDisconnect(connection.id)}
+ sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
+ >
+ Trennen
+
+ >
+ )}
+
+ {isRoadmap && (
+
+ Diese Anbindung ist geplant.
+
+ )}
+
+
+ )
+})
diff --git a/src/components/team/ConnectionSummaryList.tsx b/src/components/team/ConnectionSummaryList.tsx
new file mode 100644
index 0000000..3c9032e
--- /dev/null
+++ b/src/components/team/ConnectionSummaryList.tsx
@@ -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 (
+
+ {connections.map((connection) => {
+ const users = connection.usedByAgentIds.map(id => nameById.get(id) ?? id)
+ return (
+
+
+
+ {AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name}
+
+
+
+
+
+ {users.length > 0 ? `Genutzt von ${users.join(', ')}` : 'Noch keinem Mitarbeiter zugewiesen'}
+
+
+
+ {connection.lastSyncAt
+ ? `Zuletzt abgeglichen ${formatTeamRelative(connection.lastSyncAt)}`
+ : 'Noch kein Abgleich'}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/team/ConnectionWizard.tsx b/src/components/team/ConnectionWizard.tsx
new file mode 100644
index 0000000..e4c32f4
--- /dev/null
+++ b/src/components/team/ConnectionWizard.tsx
@@ -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 (
+
+ )
+}
+
+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(() => ({
+ 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) => 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 (
+
+ )
+}
diff --git a/src/components/team/ConnectionWizardSteps.tsx b/src/components/team/ConnectionWizardSteps.tsx
new file mode 100644
index 0000000..96bab4c
--- /dev/null
+++ b/src/components/team/ConnectionWizardSteps.tsx
@@ -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) => void
+}
+
+export function StepProvider({ connection, draft, onChange, isErp }: StepProps & { isErp: boolean }) {
+ return (
+
+
+ {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}.`}
+
+
+ {isErp && (
+ onChange({ vendor: e.target.value as AgentErpVendor })}
+ >
+ {Object.values(ErpVendor).map((vendor) => (
+
+ ))}
+
+ )}
+
+
+
+
+ Es werden keine echten Zugangsdaten erfasst. Der gesamte Verbindungsaufbau ist eine
+ Frontend-Simulation.
+
+
+
+ )
+}
+
+export function StepLabel({ connection, draft, onChange }: StepProps) {
+ return (
+
+
+ Geben Sie der Verbindung einen Namen, unter dem sie in der Bewirtschaftung
+ wiedererkennbar ist.
+
+ onChange({ label: e.target.value })}
+ helperText="Zum Beispiel nach Standort oder Team benannt."
+ />
+
+ )
+}
+
+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 (
+
+
+ Welche Rechte erhalten die digitalen Mitarbeiter auf dieser Verbindung?
+
+
+ {connection.permissions.map((permission) => (
+
+ toggle(permission.id)}
+ />
+ }
+ label={permission.label}
+ sx={{ m: 0, '& .MuiFormControlLabel-label': { fontSize: '0.875rem' } }}
+ />
+
+
+ ))}
+
+ )
+}
+
+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 (
+
+
+ Welche digitalen Mitarbeiter dürfen diese Verbindung nutzen?
+
+
+ {agents.map((agent) => (
+
+ toggle(agent.id)}
+ slotProps={{ input: { 'aria-label': `${agent.name} zuweisen` } }}
+ />
+
+
+
+ {agent.name}
+
+ {agent.role}
+
+
+ ))}
+
+ )
+}
+
+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 (
+
+
+ Prüfen Sie die Angaben. Mit «Simuliert verbinden» wird die Verbindung angelegt und im
+ Protokoll festgehalten.
+
+
+ {rows.map((row) => (
+
+
+ {row.label}
+
+ {row.value}
+
+ ))}
+
+ )
+}
diff --git a/src/components/team/TeamKpiSection.tsx b/src/components/team/TeamKpiSection.tsx
new file mode 100644
index 0000000..2f89fd8
--- /dev/null
+++ b/src/components/team/TeamKpiSection.tsx
@@ -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 (
+
+ {loading ? (
+
+ ) : (
+
+ {value ?? 0}
+
+ )}
+
+ {label}
+
+
+ {definition}
+
+
+ )
+}
+
+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 (
+
+
+
+ {/* Filter direkt über den Kennzahlen — sie wirken auf alle drei zugleich (§4.2) */}
+
+ setPeriod(e.target.value as AgentPeriod)}
+ sx={{ minWidth: 160, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
+ >
+ {Object.values(AgentPeriod).map((p) => (
+
+ ))}
+
+
+ setArea(e.target.value as AgentDomainArea | 'ALL')}
+ sx={{ minWidth: 180, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
+ >
+
+ {Object.values(AgentDomainArea).map((a) => (
+
+ ))}
+
+
+ setAgentId(e.target.value)}
+ sx={{ minWidth: 200, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
+ >
+
+ {agents.map((a) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/team/TeamPageHeader.tsx b/src/components/team/TeamPageHeader.tsx
new file mode 100644
index 0000000..8af99be
--- /dev/null
+++ b/src/components/team/TeamPageHeader.tsx
@@ -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 (
+
+
+
+
+ {title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+ {AGENT_DEMO_NOTICE}
+
+
+
+
+ {actions && (
+ {actions}
+ )}
+
+
+ {tabs}
+
+ )
+}
diff --git a/src/components/team/TeamSectionHeader.tsx b/src/components/team/TeamSectionHeader.tsx
new file mode 100644
index 0000000..ab02f50
--- /dev/null
+++ b/src/components/team/TeamSectionHeader.tsx
@@ -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 (
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {quickLink && (
+ }
+ onClick={() => navigate(quickLink.to)}
+ sx={{ textTransform: 'none', fontWeight: 600, flexShrink: 0 }}
+ >
+ {quickLink.label}
+
+ )}
+
+ )
+}
diff --git a/src/components/team/WorkItemActionDialogs.tsx b/src/components/team/WorkItemActionDialogs.tsx
new file mode 100644
index 0000000..0754f98
--- /dev/null
+++ b/src/components/team/WorkItemActionDialogs.tsx
@@ -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) {
+ const [reason, setReason] = useState('')
+
+ return (
+ <>
+ Vorgang zurückweisen
+
+
+ «{title}» wird zurückgewiesen und nicht ausgeführt. Der Vorgang verlässt die pendenten
+ Anfragen und wird im Protokoll festgehalten.
+
+ setReason(e.target.value)}
+ />
+
+
+
+
+
+ >
+ )
+}
+
+export function RejectWorkItemDialog({ open, ...rest }: RejectDialogProps) {
+ return (
+
+ )
+}
+
+// ── 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) {
+ const [draft, setDraft] = useState(fields)
+ const dirty = draft.some((f, i) => f.value !== fields[i]?.value)
+
+ return (
+ <>
+ Vorgang anpassen
+
+
+ «{title}» wird angepasst. Der Vorgang bleibt bis zur finalen Entscheidung pendent.
+
+
+ {draft.map((field, index) => (
+ {
+ const next = [...draft]
+ next[index] = { ...next[index], value: e.target.value }
+ setDraft(next)
+ }}
+ />
+ ))}
+
+
+
+
+
+
+ >
+ )
+}
+
+export function EditWorkItemDialog({ open, ...rest }: EditDialogProps) {
+ return (
+
+ )
+}
+
+// ── Rückfrage beantworten ─────────────────────────────────────────────────────
+
+interface AnswerDialogProps {
+ open: boolean
+ question: string
+ busy?: boolean
+ onCancel: () => void
+ onConfirm: (answer: string) => void
+}
+
+function AnswerBody({ question, busy, onCancel, onConfirm }: Omit) {
+ const [answer, setAnswer] = useState('')
+
+ return (
+ <>
+ Rückfrage beantworten
+
+
+ {question}
+
+
+ Die Antwort wird Teil des Ergebnisses und im Protokoll festgehalten.
+
+ setAnswer(e.target.value)}
+ autoFocus
+ />
+
+
+
+
+
+ >
+ )
+}
+
+export function AnswerQueryDialog({ open, ...rest }: AnswerDialogProps) {
+ return (
+
+ )
+}
diff --git a/src/components/team/WorkItemActions.tsx b/src/components/team/WorkItemActions.tsx
new file mode 100644
index 0000000..ce866f7
--- /dev/null
+++ b/src/components/team/WorkItemActions.tsx
@@ -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('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 (
+ <>
+
+ {can(AgentWorkItemAction.ONE_CLICK_CONFIRM) && (
+ }
+ onClick={runApprove}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Mit einem Klick bestätigen
+
+ )}
+
+ {can(AgentWorkItemAction.APPROVE) && (
+ }
+ onClick={() => setDialog('approve')}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Freigeben
+
+ )}
+
+ {can(AgentWorkItemAction.DECIDE) && (
+ }
+ onClick={() => setDialog('decide')}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Entscheidung treffen
+
+ )}
+
+ {can(AgentWorkItemAction.ANSWER_QUERY) && (
+ }
+ onClick={() => setDialog('answer')}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Rückfrage beantworten
+
+ )}
+
+ {can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && (
+ }
+ onClick={() => setDialog('edit')}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Anpassen
+
+ )}
+
+ {can(AgentWorkItemAction.REJECT) && (
+ }
+ onClick={() => setDialog('reject')}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Zurückweisen
+
+ )}
+
+ {can(AgentWorkItemAction.MARK_DONE) && (
+ }
+ onClick={() => markDone.mutate(item.id)}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Als erledigt markieren
+
+ )}
+
+ {can(AgentWorkItemAction.OPEN_SOURCE) && item.sourceReferences.length > 0 && (
+ }
+ onClick={onOpenSource}
+ sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
+ >
+ Quelle öffnen
+
+ )}
+
+
+
+
+
+
+ reject.mutate({ id: item.id, reason }, { onSuccess: close })}
+ />
+
+
+ saveEdit.mutate({ id: item.id, fields }, { onSuccess: close })
+ }
+ />
+
+ answer.mutate({ id: item.id, answer: text }, { onSuccess: close })}
+ />
+ >
+ )
+}
diff --git a/src/components/team/WorkItemCard.tsx b/src/components/team/WorkItemCard.tsx
new file mode 100644
index 0000000..a1ed12d
--- /dev/null
+++ b/src/components/team/WorkItemCard.tsx
@@ -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 (
+ 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 },
+ }}
+ >
+
+ {agent && }
+
+
+ {/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */}
+
+
+ {agent?.name ?? 'Unbekannt'}
+
+
+ {agent?.role}
+
+
+ {formatTeamDateTime(timestamp)}
+
+
+
+
+ {item.title}
+
+
+
+ {item.summary}
+
+
+ {/* Grund der Rückfrage — der wichtigste Satz bei pendenten Vorgängen */}
+ {item.requiresDecision && item.escalationReason && (
+
+
+
+ {item.escalationReason}
+
+
+ )}
+
+ {/* Fusszeile: Vorgangstyp, Objektbezug, Quellkanal, Status */}
+
+
+
+
+
+ {kindLabel}
+
+
+ {/* 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) && (
+
+
+
+ {[item.objectId, item.objectLabel].filter(Boolean).join(' · ')}
+
+
+ )}
+
+
+
+
+ {channelLabel}
+
+
+
+
+ {formatTeamRelative(timestamp)}
+
+
+
+
+
+ )
+})
diff --git a/src/components/team/WorkItemDetailDrawer.tsx b/src/components/team/WorkItemDetailDrawer.tsx
new file mode 100644
index 0000000..8cb631e
--- /dev/null
+++ b/src/components/team/WorkItemDetailDrawer.tsx
@@ -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(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 (
+
+ {/* Kopfbereich */}
+
+ {agent && }
+
+
+ {agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
+
+
+ {item?.title ?? 'Vorgang wird geladen'}
+
+
+
+
+
+
+
+ {/* Inhalt */}
+
+ {isLoading && }
+
+ {item && (
+
+ {/* Metazeile */}
+
+
+
+
+ {AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind}
+
+ {item.objectId && (
+
+ · {item.objectId}{item.objectLabel ? ` · ${item.objectLabel}` : ''}
+
+ )}
+
+ {formatTeamDateTime(item.completedAt ?? item.createdAt)}
+
+
+
+ {item.summary}
+
+ {/* Rückfrage — bei pendenten Vorgängen die wichtigste Information */}
+ {item.requiresDecision && (item.escalationReason || item.decisionQuestion) && (
+
+
+
+
+ Warum ist ein Mensch erforderlich?
+
+
+ {item.escalationReason && (
+
+ {item.escalationReason}
+
+ )}
+ {item.decisionQuestion && (
+
+ {item.decisionQuestion}
+
+ )}
+
+ )}
+
+ {item.result.length > 0 && (
+
+ {item.requiresDecision ? 'Aktueller Stand' : 'Ergebnis'}
+
+
+ )}
+
+ {item.messageThread && item.messageThread.length > 0 && (
+
+ Nachrichtenverlauf
+
+
+ )}
+
+ {item.inputs.length > 0 && (
+
+ Eingabedaten
+
+
+ )}
+
+ {item.processingSteps.length > 0 && (
+
+ Verarbeitungsschritte
+
+
+ )}
+
+ {item.sourceReferences.length > 0 && (
+
+ Fundstellen
+
+
+ )}
+
+ {item.rejectionReason && (
+
+ Begründung der Zurückweisung
+ {item.rejectionReason}
+
+ )}
+
+ {item.history.length > 0 && (
+
+ Aktionshistorie
+
+ {item.history.map((entry) => (
+
+
+ {formatTeamDateTime(entry.at)}
+
+
+ {entry.action}
+
+
+ {entry.actor}
+
+ {entry.note && (
+ <>
+
+ {entry.note}
+ >
+ )}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ {item && item.availableActions.length > 0 && (
+
+ )}
+
+ )
+}
diff --git a/src/components/team/WorkItemDetailSections.tsx b/src/components/team/WorkItemDetailSections.tsx
new file mode 100644
index 0000000..7894fb3
--- /dev/null
+++ b/src/components/team/WorkItemDetailSections.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+// ── Schlüssel-Wert-Liste ──────────────────────────────────────────────────────
+
+export function DetailFieldList({ fields }: { fields: AgentDetailField[] }) {
+ if (fields.length === 0) return null
+ return (
+
+ {fields.map((field) => (
+
+
+ {field.label}
+
+
+ {Array.isArray(field.value) ? (
+
+ {field.value.map((v) => {v})}
+
+ ) : (
+ field.value
+ )}
+
+
+ ))}
+
+ )
+}
+
+// ── Verarbeitungsschritte ─────────────────────────────────────────────────────
+
+export function ProcessingStepList({ steps }: { steps: AgentProcessingStep[] }) {
+ if (steps.length === 0) return null
+ return (
+
+ {steps.map((step, index) => (
+
+
+ {index + 1}
+
+
+ {step.label}
+ {step.detail && (
+ {step.detail}
+ )}
+
+
+ ))}
+
+ )
+}
+
+// ── 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 (
+
+ {references.map((ref) => (
+
+
+
+
+ {ref.documentName ?? ref.label}
+
+ {ref.locator && (
+ · {ref.locator}
+ )}
+
+
+ {ref.quote && (
+
+
+
+ «{ref.quote}»
+
+
+ )}
+
+ ))}
+
+ )
+}
+
+// ── Nachrichtenverlauf ────────────────────────────────────────────────────────
+
+export function MessageThread({ messages }: { messages: AgentMessageEntry[] }) {
+ if (messages.length === 0) return null
+ return (
+
+ {messages.map((message) => (
+
+
+
+ {message.from}
+ {message.to && (
+
+ {' '}an {message.to}
+
+ )}
+
+
+ {AGENT_CHANNEL_LABELS[message.channel] ?? message.channel} · {formatTeamDateTime(message.at)}
+
+
+
+ {message.subject && (
+ <>
+
+ {message.subject}
+
+
+ >
+ )}
+
+
+ {message.body}
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/team/WorkItemFilterBar.tsx b/src/components/team/WorkItemFilterBar.tsx
new file mode 100644
index 0000000..24484a8
--- /dev/null
+++ b/src/components/team/WorkItemFilterBar.tsx
@@ -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.NEWEST]: 'Neueste zuerst',
+ [AgentWorkItemSort.OLDEST]: 'Älteste zuerst',
+ [AgentWorkItemSort.PRIORITY]: 'Höchste Priorität',
+ [AgentWorkItemSort.AGENT]: 'Mitarbeiter',
+ [AgentWorkItemSort.OBJECT_ID]: 'Objekt-ID',
+}
+
+interface FilterSelectProps {
+ label: string
+ value: T | 'ALL'
+ options: readonly T[]
+ labels: Record
+ allLabel: string
+ onChange: (value: T | 'ALL') => void
+ width?: number
+}
+
+function FilterSelect({
+ label,
+ value,
+ options,
+ labels,
+ allLabel,
+ onChange,
+ width = 168,
+}: FilterSelectProps) {
+ return (
+ onChange(e.target.value as T | 'ALL')}
+ sx={{ minWidth: width, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
+ >
+
+ {options.map((option) => (
+
+ ))}
+
+ )
+}
+
+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 (
+
+ setSearch(e.target.value)}
+ sx={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ htmlInput: { 'aria-label': 'Vorgänge durchsuchen' },
+ }}
+ />
+
+ setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)}
+ width={150}
+ />
+
+ a.id)}
+ labels={agentLabels}
+ allLabel="Alle Mitarbeiter"
+ onChange={setAgentId}
+ width={190}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ setSort(e.target.value as AgentWorkItemSort)}
+ sx={{ minWidth: 178, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
+ >
+ {Object.values(AgentWorkItemSort).map((option) => (
+
+ ))}
+
+
+ {hasActiveFilter && (
+ }
+ onClick={resetFilters}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Filter zurücksetzen
+
+ )}
+
+ )
+}
diff --git a/src/components/team/WorkItemList.tsx b/src/components/team/WorkItemList.tsx
new file mode 100644
index 0000000..1ee5404
--- /dev/null
+++ b/src/components/team/WorkItemList.tsx
@@ -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 (
+
+
+
+
+
+ )
+ }
+
+ if (isError) {
+ return refetch()} />
+ }
+
+ if (items.length === 0) {
+ return } title={emptyTitle} description={emptyDescription} />
+ }
+
+ return (
+
+ {items.map((item) => (
+ a.id === item.agentId)}
+ selected={item.id === selectedId}
+ onSelect={handleSelect}
+ />
+ ))}
+
+ )
+}
diff --git a/src/components/team/__tests__/AgentBadges.test.tsx b/src/components/team/__tests__/AgentBadges.test.tsx
new file mode 100644
index 0000000..f174e72
--- /dev/null
+++ b/src/components/team/__tests__/AgentBadges.test.tsx
@@ -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()
+ expect(screen.getByText('Aktiv')).toBeInTheDocument()
+ })
+
+ it('zeigt für PAUSED «Pausiert»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Pausiert')).toBeInTheDocument()
+ })
+
+ it('zeigt für BUILDING «Im Aufbau»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Im Aufbau')).toBeInTheDocument()
+ })
+
+ it('gibt in Grösse «medium» denselben Text aus wie in «small»', () => {
+ expect(visibleText()).toBe(
+ visibleText(),
+ )
+ })
+})
+
+describe('AgentPriorityBadge', () => {
+ it('zeigt für CRITICAL «Kritisch»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Kritisch')).toBeInTheDocument()
+ })
+
+ it('zeigt für LOW «Niedrig»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Niedrig')).toBeInTheDocument()
+ })
+})
+
+describe('AgentWorkItemStatusBadge', () => {
+ it('zeigt für PENDING «Offen»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Offen')).toBeInTheDocument()
+ })
+
+ it('zeigt für REJECTED «Zurückgewiesen»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Zurückgewiesen')).toBeInTheDocument()
+ })
+
+ it('unterscheidet APPROVED und REJECTED im Text, nicht nur in der Farbe', () => {
+ const freigegeben = visibleText()
+ const zurueckgewiesen = visibleText()
+ expect(freigegeben).not.toBe(zurueckgewiesen)
+ })
+})
+
+describe('AgentConnectionStatusBadge', () => {
+ it('zeigt für CONNECTED «Verbunden»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Verbunden')).toBeInTheDocument()
+ })
+
+ it('zeigt für ROADMAP «Geplant»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Geplant')).toBeInTheDocument()
+ })
+})
+
+describe('AgentAccessBadge', () => {
+ it('zeigt für READ «Lesen»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Lesen')).toBeInTheDocument()
+ })
+
+ it('zeigt für READ_WRITE «Lesen und schreiben»', () => {
+ renderWithProviders()
+ expect(screen.getByText('Lesen und schreiben')).toBeInTheDocument()
+ })
+
+ it('macht Schreibrechte im Text sichtbar', () => {
+ expect(visibleText()).toMatch(/schreiben/i)
+ expect(visibleText()).not.toMatch(/schreiben/i)
+ })
+})
+
+describe('AgentAutonomyBadge', () => {
+ it('zeigt den Autonomiegrad AUTONOMOUS als Text', () => {
+ renderWithProviders()
+ expect(screen.getByText('Autonom')).toBeInTheDocument()
+ })
+
+ it('zeigt den Autonomiegrad APPROVAL_REQUIRED als Text', () => {
+ renderWithProviders()
+ expect(screen.getByText('Freigabe erforderlich')).toBeInTheDocument()
+ })
+
+ it('zeigt den Autonomiegrad PROPOSAL_ONLY als Text', () => {
+ renderWithProviders()
+ 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()
+
+ 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()
+ expect(screen.getByText('Autonom')).toBeInTheDocument()
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ })
+})
+
+describe('AgentChannelBadge', () => {
+ it('übersetzt den Kanalschlüssel in eine Klartextbezeichnung', () => {
+ renderWithProviders()
+ 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()
+ 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: }),
+ ),
+ ...Object.values(AgentWorkItemPriority).map(
+ (wert): BadgeCase => ({ name: `AgentPriorityBadge/${wert}`, element: }),
+ ),
+ ...Object.values(AgentWorkItemStatus).map(
+ (wert): BadgeCase => ({
+ name: `AgentWorkItemStatusBadge/${wert}`,
+ element: ,
+ }),
+ ),
+ ...Object.values(AgentConnectionStatus).map(
+ (wert): BadgeCase => ({
+ name: `AgentConnectionStatusBadge/${wert}`,
+ element: ,
+ }),
+ ),
+ ...Object.values(AgentAccessLevel).map(
+ (wert): BadgeCase => ({ name: `AgentAccessBadge/${wert}`, element: }),
+ ),
+ ...Object.values(AgentAutonomyLevel).map(
+ (wert): BadgeCase => ({ name: `AgentAutonomyBadge/${wert}`, element: }),
+ ),
+ ...Object.values(AgentChannelType).map(
+ (wert): BadgeCase => ({ name: `AgentChannelBadge/${wert}`, element: }),
+ ),
+]
+
+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(),
+ )
+ expect(new Set(texte).size).toBe(texte.length)
+ })
+})
diff --git a/src/components/team/__tests__/AgentCard.test.tsx b/src/components/team/__tests__/AgentCard.test.tsx
new file mode 100644
index 0000000..e410503
--- /dev/null
+++ b/src/components/team/__tests__/AgentCard.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(onOpen).not.toHaveBeenCalled()
+ })
+
+ it('zeigt bei einem pausierten Mitarbeiter «Pausiert» statt «Aktiv»', () => {
+ const paused: TeamAgent = { ...ferdi, status: AgentStatus.PAUSED }
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ // `alt` allein genügt bei MUI nicht: ohne `src` rendert Avatar kein
,
+ // 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()
+ })
+})
diff --git a/src/components/team/__tests__/AgentDossierTabs.test.tsx b/src/components/team/__tests__/AgentDossierTabs.test.tsx
new file mode 100644
index 0000000..1e299d0
--- /dev/null
+++ b/src/components/team/__tests__/AgentDossierTabs.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ for (const setting of sina.settings) {
+ expect(screen.getByText(setting.label)).toBeInTheDocument()
+ }
+ })
+
+ it('zeigt im Ausgangszustand keine Leiste für ungespeicherte Änderungen', () => {
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
+})
diff --git a/src/components/team/__tests__/WorkItemCard.test.tsx b/src/components/team/__tests__/WorkItemCard.test.tsx
new file mode 100644
index 0000000..074fc0e
--- /dev/null
+++ b/src/components/team/__tests__/WorkItemCard.test.tsx
@@ -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(
+ ,
+ )
+ 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()
+
+ expect(screen.getByRole('button')).not.toHaveAttribute('aria-current', 'true')
+ })
+})
diff --git a/src/components/team/index.ts b/src/components/team/index.ts
new file mode 100644
index 0000000..ed8db54
--- /dev/null
+++ b/src/components/team/index.ts
@@ -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'
diff --git a/src/components/team/teamFilterUtils.ts b/src/components/team/teamFilterUtils.ts
new file mode 100644
index 0000000..169167d
--- /dev/null
+++ b/src/components/team/teamFilterUtils.ts
@@ -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(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 }
+}
diff --git a/src/components/team/teamTokens.ts b/src/components/team/teamTokens.ts
new file mode 100644
index 0000000..6ea2960
--- /dev/null
+++ b/src/components/team/teamTokens.ts
@@ -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.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.ACTIVE]: 'confidenceHigh',
+ [AgentStatus.PAUSED]: 'confidenceMedium',
+ [AgentStatus.BUILDING]: 'info',
+}
+
+export const PRIORITY_VARIANT: Record = {
+ [AgentWorkItemPriority.CRITICAL]: 'riskHigh',
+ [AgentWorkItemPriority.HIGH]: 'confidenceMedium',
+ [AgentWorkItemPriority.MEDIUM]: 'info',
+ [AgentWorkItemPriority.LOW]: 'muted',
+}
+
+export const WORK_ITEM_STATUS_VARIANT: Record = {
+ [AgentWorkItemStatus.PENDING]: 'confidenceMedium',
+ [AgentWorkItemStatus.APPROVED]: 'confidenceHigh',
+ [AgentWorkItemStatus.COMPLETED]: 'confidenceHigh',
+ [AgentWorkItemStatus.REJECTED]: 'riskHigh',
+ [AgentWorkItemStatus.EDITING]: 'info',
+}
+
+export const CONNECTION_STATUS_VARIANT: Record = {
+ [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.READ]: 'muted',
+ [AgentAccessLevel.WRITE]: 'confidenceMedium',
+ [AgentAccessLevel.READ_WRITE]: 'info',
+}
+
+export const PROTOCOL_STATUS_SURFACE: Record = {
+ [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.SUCCESS]: DS_TEXT.success,
+ [AgentProtocolStatus.WARNING]: DS_TEXT.warning,
+ [AgentProtocolStatus.ERROR]: DS_TEXT.error,
+ [AgentProtocolStatus.INFO]: DS_TEXT.info,
+}
diff --git a/src/pages/supply/Bearbeitungsverlauf.tsx b/src/pages/supply/Bearbeitungsverlauf.tsx
new file mode 100644
index 0000000..8c84fbe
--- /dev/null
+++ b/src/pages/supply/Bearbeitungsverlauf.tsx
@@ -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.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 (
+
+ handleTabChange(v)}
+ sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.95rem' } }}
+ >
+
+
+
+ }
+ />
+
+
+
+
+ {activeTab === HistoryTab.PENDING ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/pages/supply/KanaeleSysteme.tsx b/src/pages/supply/KanaeleSysteme.tsx
new file mode 100644
index 0000000..be6b84b
--- /dev/null
+++ b/src/pages/supply/KanaeleSysteme.tsx
@@ -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(null)
+ const [disconnectId, setDisconnectId] = useState(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 (
+
+
+
+
+ {isError ? (
+ refetch()} />
+ ) : (
+ <>
+
+ {connections.filter(c => c.status === AgentConnectionStatus.CONNECTED).length} von {connections.length}{' '}
+ Verbindungen sind eingerichtet.
+
+
+
+ {isLoading
+ ? [0, 1, 2, 3, 4, 5].map((i) => )
+ : connections.map((connection) => (
+
+ ))}
+
+ >
+ )}
+
+
+ setWizardId(null)}
+ />
+
+ {
+ if (pendingDisconnect) disconnect.mutate(pendingDisconnect.id)
+ setDisconnectId(null)
+ }}
+ onCancel={() => setDisconnectId(null)}
+ />
+
+ )
+}
diff --git a/src/pages/supply/Personalverwaltung.tsx b/src/pages/supply/Personalverwaltung.tsx
new file mode 100644
index 0000000..ccf1b67
--- /dev/null
+++ b/src/pages/supply/Personalverwaltung.tsx
@@ -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 (
+
+
+
+ {isError ? (
+ refetch()} />
+ ) : (
+
+ {/* Ebene 2: Agentenliste */}
+
+
+ {/* Ebene 3: Personaldossier */}
+
+ {isLoading && }
+
+ {!isLoading && !selectedAgent && (
+
+ )}
+
+ {selectedAgent && (
+ <>
+
+
+
+
+ selectTab(v)}
+ variant="scrollable"
+ scrollButtons="auto"
+ allowScrollButtonsMobile
+ sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9375rem' } }}
+ >
+ {DOSSIER_TABS.map((t) => (
+
+ ))}
+
+
+
+ {/* `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. */}
+
+ {activeSegment === 'aufgaben' && }
+ {activeSegment === 'kanaele' && }
+ {activeSegment === 'systeme' && }
+ {activeSegment === 'einstellungen' && }
+ {activeSegment === 'protokoll' && }
+
+ >
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/src/pages/supply/Teamuebersicht.tsx b/src/pages/supply/Teamuebersicht.tsx
new file mode 100644
index 0000000..99610e8
--- /dev/null
+++ b/src/pages/supply/Teamuebersicht.tsx
@@ -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 (
+
+ }
+ disabled={resetDemo.isPending}
+ onClick={() => resetDemo.mutate()}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ {resetDemo.isPending ? 'Wird zurückgesetzt …' : 'Demo zurücksetzen'}
+
+ }
+ />
+
+
+ {isError ? (
+ refetch()} />
+ ) : (
+ <>
+
+
+
+
+
+
+ {isLoading
+ ? [0, 1, 2, 3].map((i) => )
+ : agents.map((agent) => (
+
+ ))}
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}