From 78e2dab655326d4d8939ca9068bd4643292a81fc Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Mon, 3 Aug 2026 16:56:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(team):=20Oberfl=C3=A4che=20der=20Team?= =?UTF-8?q?=C3=BCbersicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Seiten, eingebettet in die bestehende App-Shell — keine zweite Sidebar, keine zweite Kopfzeile, kein eigenes Property-On-Branding: - Teamübersicht: Auswertung (drei Kennzahlen, gemeinsam filterbar nach Zeitraum, Bereich und Mitarbeiter), Kernteam, Kanäle & Systeme — in dieser Reihenfolge - Bearbeitungsverlauf: zwei Reiter, neun Filterdimensionen, Detail-Drawer mit Ergebnis, Eingabedaten, Verarbeitungsschritten, Fundstellen, Nachrichtenverlauf und Aktionshistorie; Freigeben, Anpassen, Zurückweisen, Rückfrage beantworten - Personalverwaltung: dreigeteilt (Hauptnavigation, Agentenliste, Dossier) mit fünf Reitern — Aufgaben, Kanäle, Systeme, Einstellungen, Protokoll - Kanäle & Systeme: neun Verbindungen, mehrstufiger Assistent, ERP-Auswahl, deterministischer Verbindungstest Navigation: NavItem um `children` erweitert, "Teamübersicht" steht exakt zwischen "Meine Objekte" und "Reminder Manager", die drei Subreiter bleiben eingerückt sichtbar, solange man im Funktionsbereich ist. Subreiter sind echte Routen, keine lokalen Tabs. Die App kannte bisher keine Feature-Route mit eigenem Outlet und baut Tabs sonst über lokalen useState — hier trug das nicht: die Subreiter müssen in der Sidebar stehen und deep-linkbar sein. Die Routen sind flache Geschwister, kein Outlet-Baum. Freigabepflichtige Aktionen laufen über einen Bestätigungsdialog mit sichtbarer Konsequenz; die 1-Klick-Bestätigung ist die fachlich vorgesehene Ausnahme. Jeder Status trägt neben der Farbe immer einen Text. Null rohe Hex-Werte: check:tokens bleibt unverändert bei 2249. Der in der Spezifikation genannte dunkelblaue Banner "Heute geleistet …" existiert in dieser Codebasis nicht — er wird deshalb auch nicht erzeugt. Co-Authored-By: Claude Opus 5 (1M context) --- src/App.tsx | 18 ++ src/components/layout/AppShellSidebar.tsx | 54 +++- src/components/layout/appShellConfig.ts | 35 +++ src/components/team/AgentAvatar.tsx | 87 ++++++ src/components/team/AgentBadges.tsx | 121 ++++++++ src/components/team/AgentCard.tsx | 98 +++++++ src/components/team/AgentChannelsTab.tsx | 249 ++++++++++++++++ src/components/team/AgentDossierHeader.tsx | 142 ++++++++++ src/components/team/AgentInfoBox.tsx | 91 ++++++ src/components/team/AgentListPanel.tsx | 129 +++++++++ src/components/team/AgentProtocolTab.tsx | 234 +++++++++++++++ src/components/team/AgentSettingsTab.tsx | 243 ++++++++++++++++ src/components/team/AgentSystemsTab.tsx | 222 +++++++++++++++ src/components/team/AgentTasksTab.tsx | 235 +++++++++++++++ src/components/team/ConnectionCard.tsx | 148 ++++++++++ src/components/team/ConnectionSummaryList.tsx | 66 +++++ src/components/team/ConnectionWizard.tsx | 161 +++++++++++ src/components/team/ConnectionWizardSteps.tsx | 235 +++++++++++++++ src/components/team/TeamKpiSection.tsx | 148 ++++++++++ src/components/team/TeamPageHeader.tsx | 76 +++++ src/components/team/TeamSectionHeader.tsx | 56 ++++ src/components/team/WorkItemActionDialogs.tsx | 209 ++++++++++++++ src/components/team/WorkItemActions.tsx | 219 ++++++++++++++ src/components/team/WorkItemCard.tsx | 141 +++++++++ src/components/team/WorkItemDetailDrawer.tsx | 206 ++++++++++++++ .../team/WorkItemDetailSections.tsx | 197 +++++++++++++ src/components/team/WorkItemFilterBar.tsx | 235 +++++++++++++++ src/components/team/WorkItemList.tsx | 57 ++++ .../team/__tests__/AgentBadges.test.tsx | 240 ++++++++++++++++ .../team/__tests__/AgentCard.test.tsx | 164 +++++++++++ .../team/__tests__/AgentDossierTabs.test.tsx | 267 ++++++++++++++++++ .../team/__tests__/WorkItemCard.test.tsx | 189 +++++++++++++ src/components/team/index.ts | 66 +++++ src/components/team/teamFilterUtils.ts | 71 +++++ src/components/team/teamTokens.ts | 75 +++++ src/pages/supply/Bearbeitungsverlauf.tsx | 109 +++++++ src/pages/supply/KanaeleSysteme.tsx | 113 ++++++++ src/pages/supply/Personalverwaltung.tsx | 135 +++++++++ src/pages/supply/Teamuebersicht.tsx | 99 +++++++ 39 files changed, 5638 insertions(+), 2 deletions(-) create mode 100644 src/components/team/AgentAvatar.tsx create mode 100644 src/components/team/AgentBadges.tsx create mode 100644 src/components/team/AgentCard.tsx create mode 100644 src/components/team/AgentChannelsTab.tsx create mode 100644 src/components/team/AgentDossierHeader.tsx create mode 100644 src/components/team/AgentInfoBox.tsx create mode 100644 src/components/team/AgentListPanel.tsx create mode 100644 src/components/team/AgentProtocolTab.tsx create mode 100644 src/components/team/AgentSettingsTab.tsx create mode 100644 src/components/team/AgentSystemsTab.tsx create mode 100644 src/components/team/AgentTasksTab.tsx create mode 100644 src/components/team/ConnectionCard.tsx create mode 100644 src/components/team/ConnectionSummaryList.tsx create mode 100644 src/components/team/ConnectionWizard.tsx create mode 100644 src/components/team/ConnectionWizardSteps.tsx create mode 100644 src/components/team/TeamKpiSection.tsx create mode 100644 src/components/team/TeamPageHeader.tsx create mode 100644 src/components/team/TeamSectionHeader.tsx create mode 100644 src/components/team/WorkItemActionDialogs.tsx create mode 100644 src/components/team/WorkItemActions.tsx create mode 100644 src/components/team/WorkItemCard.tsx create mode 100644 src/components/team/WorkItemDetailDrawer.tsx create mode 100644 src/components/team/WorkItemDetailSections.tsx create mode 100644 src/components/team/WorkItemFilterBar.tsx create mode 100644 src/components/team/WorkItemList.tsx create mode 100644 src/components/team/__tests__/AgentBadges.test.tsx create mode 100644 src/components/team/__tests__/AgentCard.test.tsx create mode 100644 src/components/team/__tests__/AgentDossierTabs.test.tsx create mode 100644 src/components/team/__tests__/WorkItemCard.test.tsx create mode 100644 src/components/team/index.ts create mode 100644 src/components/team/teamFilterUtils.ts create mode 100644 src/components/team/teamTokens.ts create mode 100644 src/pages/supply/Bearbeitungsverlauf.tsx create mode 100644 src/pages/supply/KanaeleSysteme.tsx create mode 100644 src/pages/supply/Personalverwaltung.tsx create mode 100644 src/pages/supply/Teamuebersicht.tsx 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'} + + + + + ) +}) 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'} + + + + + ) +}) + +// ── 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 ( + + {channel && } + + ) +} + +// ── 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) => ( + + {agent.name} · {agent.role} + + ))} + + + ) + } + + 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}> + {allLabel} + {options.map((option) => {labels[option] ?? 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) => {o.label}) + + 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 + + + + + )} + + {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) => ( + + ))} + + )} + + + + + {isConnected && ( + <> + + + + )} + + {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 ( + + + {connection.name} verbinden + + + + + {STEPS.map((label) => ( + + {label} + + ))} + + + + {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } + + + + + + + {step > 0 && ( + + )} + + {step < STEPS.length - 1 ? ( + + ) : ( + + )} + + + ) +} 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) => ( + {AGENT_ERP_VENDOR_LABELS[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) => ( + {AGENT_PERIOD_LABELS[p]} + ))} + + + setArea(e.target.value as AgentDomainArea | 'ALL')} + sx={{ minWidth: 180, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }} + > + Alle Bereiche + {Object.values(AgentDomainArea).map((a) => ( + {AGENT_DOMAIN_AREA_LABELS[a]} + ))} + + + setAgentId(e.target.value)} + sx={{ minWidth: 200, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }} + > + Alle Mitarbeiter + {agents.map((a) => ( + {a.name} · {a.role} + ))} + + + + + + + + + + ) +} 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 && ( + + )} + + ) +} 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 ( + + {open && } + + ) +} + +// ── 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 ( + + {open && } + + ) +} + +// ── 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 ( + + {open && } + + ) +} 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) && ( + + )} + + {can(AgentWorkItemAction.APPROVE) && ( + + )} + + {can(AgentWorkItemAction.DECIDE) && ( + + )} + + {can(AgentWorkItemAction.ANSWER_QUERY) && ( + + )} + + {can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && ( + + )} + + {can(AgentWorkItemAction.REJECT) && ( + + )} + + {can(AgentWorkItemAction.MARK_DONE) && ( + + )} + + {can(AgentWorkItemAction.OPEN_SOURCE) && item.sourceReferences.length > 0 && ( + + )} + + + + + + + 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 } }} + > + {allLabel} + {options.map((option) => ( + + {labels[option] ?? 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) => ( + {SORT_LABELS[option]} + ))} + + + {hasActiveFilter && ( + + )} + + ) +} 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) => ( + + ))} + + + + + + + + + )} + + + ) +}