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) => ( + + ))} + + + + + + + + + )} + + + ) +}