diff --git a/src/components/layout/GlobalNavMenu.tsx b/src/components/layout/GlobalNavMenu.tsx index 9c32d6b..92d359f 100644 --- a/src/components/layout/GlobalNavMenu.tsx +++ b/src/components/layout/GlobalNavMenu.tsx @@ -5,7 +5,7 @@ import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useToastStore } from '../../stores/toastStore' import { AgentAvatar } from '../team' -import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces' +import { AGENT_NAV_ENTRIES } from '../../lib/agentWorkspaces' import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants' import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds' @@ -25,7 +25,7 @@ const ROLE_LABELS: Record = { * rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen, * die vorher im Profilmenü lagen. * - * Die Agentenliste kommt aus `AGENT_WORKSPACES` — derselben zentralen + * Die Agentenliste kommt aus `AGENT_NAV_ENTRIES` — derselben zentralen * Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen, * Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24, * Schritt 7). @@ -116,7 +116,7 @@ export function GlobalNavMenu() { go(ROUTES.SUPPLY.TEAM)} /> {/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */} - {AGENT_WORKSPACES.map(agent => ( + {AGENT_NAV_ENTRIES.map(agent => ( go(agent.path)}> {agent.name} diff --git a/src/components/layout/__tests__/appShellConfig.test.ts b/src/components/layout/__tests__/appShellConfig.test.ts index a072e4c..723e266 100644 --- a/src/components/layout/__tests__/appShellConfig.test.ts +++ b/src/components/layout/__tests__/appShellConfig.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest' import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig' import { WorkspaceType } from '../../../domain/enums' import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants' -import { AGENT_WORKSPACES } from '../../../lib/agentWorkspaces' +import { AGENT_NAV_ENTRIES } from '../../../lib/agentWorkspaces' const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems @@ -47,9 +47,9 @@ describe('Navigationseintrag «Meine Agenten»', () => { expect(agenten?.path).toBe(ROUTES.SUPPLY.TEAM) }) - it('trägt die fünf Agenten in verbindlicher Reihenfolge', () => { + it('trägt Thomas zuoberst und darunter die fünf in verbindlicher Reihenfolge', () => { const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) - expect(agenten?.children?.map(c => c.label)).toEqual(['Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina']) + expect(agenten?.children?.map(c => c.label)).toEqual(['Thomas', 'Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina']) }) it('gibt jedem Agenten Porträt-ID und Funktionsbezeichnung mit', () => { @@ -63,7 +63,7 @@ describe('Navigationseintrag «Meine Agenten»', () => { it('verweist mit jedem Agenten auf eine eigene Seite', () => { const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) const paths = agenten?.children?.map(c => c.path) ?? [] - expect(paths).toEqual(AGENT_WORKSPACES.map(a => a.path)) + expect(paths).toEqual(AGENT_NAV_ENTRIES.map(a => a.path)) expect(new Set(paths).size).toBe(paths.length) }) diff --git a/src/components/layout/appShellConfig.ts b/src/components/layout/appShellConfig.ts index 4750d0d..e54f377 100644 --- a/src/components/layout/appShellConfig.ts +++ b/src/components/layout/appShellConfig.ts @@ -1,6 +1,6 @@ import { WorkspaceType } from '../../domain/enums' import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants' -import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces' +import { AGENT_NAV_ENTRIES } from '../../lib/agentWorkspaces' import type { LucideIcon } from 'lucide-react' import { Building2, @@ -75,7 +75,7 @@ export const WORKSPACE_CONFIG: Record = { icon: Users, // Die drei Verwaltungsbereiche sind Reiter der Hauptseite geworden; // hier stehen ab Runde 4 die fünf digitalen Mitarbeitenden selbst. - children: AGENT_WORKSPACES.map(a => ({ + children: AGENT_NAV_ENTRIES.map(a => ({ path: a.path, label: a.name, agentId: a.id, diff --git a/src/components/team/AgentChannelsTab.tsx b/src/components/team/AgentChannelsTab.tsx index a06ac01..14bae0d 100644 --- a/src/components/team/AgentChannelsTab.tsx +++ b/src/components/team/AgentChannelsTab.tsx @@ -12,14 +12,21 @@ import { memo, useCallback, useMemo, useState } from 'react' import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, - FormControlLabel, Switch, TextField, Typography, + FormControlLabel, IconButton, Switch, TextField, Tooltip, Typography, } from '@mui/material' -import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Settings2 } from 'lucide-react' +import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Pencil, Plus, Settings2, Trash2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import type { AgentChannel, TeamAgent } from '../../domain/teamAgent' -import { AgentChannelDirection, AgentConnectionStatus } from '../../domain/teamAgent' +import { AgentChannelDirection, AgentChannelType, AgentConnectionStatus } from '../../domain/teamAgent' import { AgentConnectionStatusBadge } from './AgentBadges' -import { AGENT_CHANNEL_DIRECTION_LABELS, AGENT_CHANNEL_LABELS } from '../../lib/constants' +import { AgentEntityDialog } from './AgentEntityDialog' +import type { EntityDraft, EntityField } from './AgentEntityDialog' +import { newEntityId, removeById, upsertById } from './agentEntityEditing' +import { + AGENT_CHANNEL_DIRECTION_LABELS, + AGENT_CHANNEL_LABELS, + AGENT_CONNECTION_STATUS_LABELS, +} from '../../lib/constants' import { useSaveAgentChannels } from '../../hooks/useTeamAgents' import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' @@ -40,15 +47,65 @@ const TEST_RESULT_BY_STATUS: Record = { [AgentConnectionStatus.ROADMAP]: { ok: false, message: 'Verbindung fehlgeschlagen. Dieser Kanal ist erst geplant und noch nicht eingerichtet — ein Test ist nach der Einrichtung möglich.' }, } +// ── Maske zum Anlegen und Bearbeiten (Runde 10, §2A) ────────────────────────── + +const CHANNEL_FIELDS: EntityField[] = [ + { + key: 'type', + label: 'Kanal', + type: 'select', + required: true, + options: Object.values(AgentChannelType).map((t) => ({ + value: t, + label: AGENT_CHANNEL_LABELS[t] ?? t, + })), + }, + { + key: 'direction', + label: 'Richtung', + type: 'select', + required: true, + options: Object.values(AgentChannelDirection).map((d) => ({ + value: d, + label: AGENT_CHANNEL_DIRECTION_LABELS[d] ?? d, + })), + }, + { + key: 'status', + label: 'Verbindung', + type: 'select', + required: true, + options: Object.values(AgentConnectionStatus).map((s) => ({ + value: s, + label: AGENT_CONNECTION_STATUS_LABELS[s] ?? s, + })), + helperText: 'Ein neu erfasster Kanal gilt erst als verbunden, wenn er eingerichtet ist.', + }, + { + key: 'description', + label: 'Wofür wird der Kanal genutzt?', + type: 'multiline', + required: true, + }, + { + key: 'displayName', + label: 'Anzeigename', + helperText: 'Steht so im Konfigurationsdialog.', + }, +] + // ── Kanalzeile ──────────────────────────────────────────────────────────────── interface RowProps { channel: AgentChannel + editing: boolean onToggle: (channelId: string, enabled: boolean) => void onConfigure: (channelId: string) => void + onEdit: (channelId: string) => void + onDelete: (channelId: string) => void } -const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }: RowProps) { +const ChannelRow = memo(function ChannelRow({ channel, editing, onToggle, onConfigure, onEdit, onDelete }: RowProps) { const label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type const DirectionIcon = DIRECTION_ICON[channel.direction] @@ -71,6 +128,20 @@ const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }: {AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction} + {editing && ( + <> + + onEdit(channel.id)} sx={{ color: DS_TEXT.secondary }}> + + + + + onDelete(channel.id)} sx={{ color: DS_TEXT.secondary }}> + + + + + )} @@ -197,6 +268,10 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) { const { mutate: saveChannels } = useSaveAgentChannels() const [openChannelId, setOpenChannelId] = useState(null) + /** `null` = Maske zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Kanals. */ + const [bearbeitet, setBearbeitet] = useState(null) + const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false) + const channels = agent.channels // Beide Kennzahlen in einem Durchlauf (§10.4). @@ -222,15 +297,72 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) { const handleConfigure = useCallback((channelId: string) => setOpenChannelId(channelId), []) const handleClose = useCallback(() => setOpenChannelId(null), []) + const handleDelete = useCallback( + (channelId: string) => saveChannels({ id: agent.id, channels: removeById(channels, channelId) }), + [agent.id, channels, saveChannels], + ) + + /** + * Anlegen und Bearbeiten teilen sich die Maske. Gespeichert wird sofort — + * ein Kanal ist wie der Aktiv-Schalter ein Ein/Aus-Entscheid, kein Formular + * mit Zwischenstand. + */ + const handleDialogSave = useCallback( + (werte: EntityDraft, active: boolean) => { + const bestehend = bearbeitet !== 'new' ? channels.find((c) => c.id === bearbeitet) : undefined + const eintrag: AgentChannel = { + id: bestehend?.id ?? newEntityId(agent.id, 'channel'), + type: werte.type as AgentChannelType, + direction: werte.direction as AgentChannelDirection, + status: werte.status as AgentConnectionStatus, + description: werte.description.trim(), + enabled: active, + ...(bestehend?.optional ? { optional: true } : {}), + config: { + ...(bestehend?.config ?? { defaultRecipients: [], autoReplyEnabled: false }), + displayName: werte.displayName?.trim() || (AGENT_CHANNEL_LABELS[werte.type] ?? werte.type), + }, + } + saveChannels({ id: agent.id, channels: upsertById(channels, eintrag) }) + setBearbeitet(null) + }, + [bearbeitet, channels, agent.id, saveChannels], + ) + + const inBearbeitung = bearbeitet && bearbeitet !== 'new' + ? channels.find((c) => c.id === bearbeitet) + : undefined + 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. @@ -238,12 +370,38 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) { ) : ( {channels.map((channel) => ( - + ))} )} + + setBearbeitet(null)} + /> ) } diff --git a/src/components/team/AgentChatTranscript.tsx b/src/components/team/AgentChatTranscript.tsx index e31baf3..734073d 100644 --- a/src/components/team/AgentChatTranscript.tsx +++ b/src/components/team/AgentChatTranscript.tsx @@ -1,8 +1,18 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' import { Box, Typography } from '@mui/material' import { useAgentChatStore } from '../../stores/agentChatStore' import { AgentAvatar } from './AgentAvatar' import { DS_BORDER, DS_SLATE, DS_TEXT } from '../../lib/ds' +/** + * Höhe des Verlaufsfensters. + * + * Der Verlauf braucht eine feste Obergrenze, sonst wächst er mit jeder Antwort + * und schiebt die Seite darunter weg — genau das Springen, das behoben werden + * soll. Innerhalb dieser Höhe scrollt der Verlauf für sich. + */ +const TRANSCRIPT_MAX_HEIGHT = 420 + /** * Der Gesprächsverlauf unter dem Eingabefeld (Runde 9, §3). * @@ -19,13 +29,70 @@ export function AgentChatTranscript() { const messages = useAgentChatStore(s => s.messages) const agent = useAgentChatStore(s => s.agent) + const containerRef = useRef(null) + /** Die zuletzt gestellte Frage — an ihr richtet sich die Scrollposition aus. */ + const lastQuestionRef = useRef(null) + const zuletztGescrollt = useRef(0) + + /** + * Nach einer Antwort den Verlauf so stellen, dass die eigene Frage oben steht + * und die Antwort direkt darunter beginnt (Runde 10, §4). + * + * Ausschliesslich über `scrollTop` des Containers. Kein `scrollIntoView` und + * kein `window.scrollTo`: Beide bewegen die Seite mit, und genau dieses + * Wegspringen des ganzen Bildschirms war die Beschwerde. Der Browser-Viewport + * bleibt hier unberührt. + * + * `useLayoutEffect`, damit die Korrektur vor dem Zeichnen sitzt — sonst sieht + * man den Sprung einmal kurz. + */ + useLayoutEffect(() => { + const container = containerRef.current + const frage = lastQuestionRef.current + if (!container || !frage || messages.length === zuletztGescrollt.current) return + zuletztGescrollt.current = messages.length + // Abstand der Frage zum oberen Rand des Verlaufs, minus ein schmaler + // Vorlauf, damit sie nicht bündig an der Kante klebt. + container.scrollTop = Math.max(0, frage.offsetTop - container.offsetTop - 8) + }, [messages.length]) + + // Ein Agentenwechsel leert den Verlauf; dann darf die nächste Antwort wieder + // ausrichten, statt die alte Zählung fortzuschreiben. + useEffect(() => { + if (messages.length === 0) zuletztGescrollt.current = 0 + }, [messages.length]) + if (messages.length === 0 || !agent) return null + const letzteFrageIndex = messages.map(m => m.from).lastIndexOf('user') + return ( - + {messages.map((m, i) => ( m.from === 'user' ? ( - + + +interface Props { + open: boolean + /** + * Kennung des bearbeiteten Eintrags — bei offenem Dialog ein Wechsel darauf + * baut die Maske neu auf. Bewusst über den React-Schlüssel statt über einen Effekt: ein + * Effekt würde den Entwurf nachträglich überschreiben und dabei bereits + * getippten Text verlieren. + */ + entityKey?: string + /** Überschrift, z. B. «Aufgabe bearbeiten» oder «Neues System anbinden». */ + title: string + fields: EntityField[] + /** Vorbelegung; beim Anlegen leer. */ + initial: EntityDraft + /** Beschriftung des Aktiv-Schalters; entfällt, wenn nicht gesetzt. */ + activeLabel?: string + initialActive?: boolean + onSave: (draft: EntityDraft, active: boolean) => void + onClose: () => void +} + +/** + * Gemeinsame Maske zum Anlegen und Bearbeiten von Aufgaben, Systemen und + * Kanälen (Runde 10, §2). + * + * Der Entwurf lebt lokal und geht erst beim Speichern hinaus — bei jedem + * Tastendruck zu speichern hiesse, jeden Zwischenstand zum gültigen Stand zu + * erklären. Über `key` auf dem Dialoginhalt entsteht die Maske bei jedem + * Öffnen neu, statt dass ein Effekt den alten Entwurf nachträglich überschreibt. + */ +export function AgentEntityDialog({ + open, title, fields, initial, activeLabel, initialActive = true, entityKey, onSave, onClose, +}: Props) { + return ( + + {title} + {open && ( + + )} + + ) +} + +function DialogBody({ + fields, initial, activeLabel, initialActive, onSave, onClose, +}: Omit) { + const [draft, setDraft] = useState(initial) + const [active, setActive] = useState(initialActive ?? true) + const [touched, setTouched] = useState(false) + + const fehlend = fields.filter(f => f.required && !(draft[f.key] ?? '').trim()).map(f => f.key) + + function handleSave() { + setTouched(true) + if (fehlend.length > 0) return + onSave(draft, active) + } + + return ( + <> + + + {fields.map(f => ( + setDraft(prev => ({ ...prev, [f.key]: e.target.value }))} + placeholder={f.placeholder} + required={f.required} + error={touched && fehlend.includes(f.key)} + helperText={ + touched && fehlend.includes(f.key) + ? 'Dieses Feld wird gebraucht.' + : f.helperText + } + size="small" + fullWidth + > + {(f.options ?? []).map(o => ( + + {o.label} + + ))} + + ))} + + {activeLabel && ( + setActive(v)} />} + label={active ? activeLabel : `${activeLabel} — derzeit inaktiv`} + slotProps={{ typography: { variant: 'body2' } }} + /> + )} + + + + + + + + + ) +} diff --git a/src/components/team/AgentSystemsTab.tsx b/src/components/team/AgentSystemsTab.tsx index 9279ec4..3fa4c36 100644 --- a/src/components/team/AgentSystemsTab.tsx +++ b/src/components/team/AgentSystemsTab.tsx @@ -1,26 +1,38 @@ /** - * Property On — Reiter «Systeme» im Personalblatt (§10). + * Property On — Reiter «Systeme» im Personalblatt (§10, Runde 10 §3). * - * 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. + * Zeigt und pflegt die Fachsysteme und Datenquellen eines digitalen + * Mitarbeiters. Seit Runde 10 ist der Reiter nicht mehr nur Anzeige: Zugänge + * lassen sich hier anlegen, ändern, zu- und abschalten — dort, wo man sie sieht. * - * 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). + * Bewusst eine einzige Liste statt getrennter Gruppen «lesend» und + * «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser + * Mitarbeiter», nicht «wie viele davon sind lesend». Der Zugriffstyp steht + * weiterhin an jedem Eintrag — als Badge, als Icon und als Klartextsatz, denn + * 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 { memo, useCallback, useState } from 'react' +import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material' +import { Eye, KeyRound, PenLine, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import { AgentAccessLevel } from '../../domain/teamAgent' +import { + AgentAccessLevel, + AgentConnectionStatus, + AgentSystemType, +} 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 { GenericBadge } from '../shared/GenericBadge' +import { AgentEntityDialog } from './AgentEntityDialog' +import type { EntityDraft, EntityField } from './AgentEntityDialog' +import { newEntityId, removeById, upsertById } from './agentEntityEditing' +import { useSaveAgentSystems } from '../../hooks/useTeamAgents' +import { + AGENT_ACCESS_LABELS, + AGENT_CONNECTION_STATUS_LABELS, + AGENT_SYSTEM_LABELS, +} from '../../lib/constants' import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds' // ── Darstellung je Zugriffstyp ──────────────────────────────────────────────── @@ -58,22 +70,79 @@ const ACCESS_APPEARANCE: Record = { }, } -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` +/** Ohne Feld gilt ein Zugang als aktiv — der Katalog führt die Zugänge ohne Schalter. */ +function istAktiv(system: AgentSystem): boolean { + return system.enabled !== false } +// ── Maske ───────────────────────────────────────────────────────────────────── + +const SYSTEM_FIELDS: EntityField[] = [ + { + key: 'type', + label: 'System', + type: 'select', + required: true, + options: Object.values(AgentSystemType).map((t) => ({ + value: t, + label: AGENT_SYSTEM_LABELS[t] ?? t, + })), + }, + { + key: 'access', + label: 'Zugriff', + type: 'select', + required: true, + options: Object.values(AgentAccessLevel).map((a) => ({ + value: a, + label: AGENT_ACCESS_LABELS[a] ?? a, + })), + helperText: 'Schreibrechte verändern Kundendaten in der Bewirtschaftung.', + }, + { + key: 'status', + label: 'Verbindung', + type: 'select', + required: true, + options: Object.values(AgentConnectionStatus).map((s) => ({ + value: s, + label: AGENT_CONNECTION_STATUS_LABELS[s] ?? s, + })), + helperText: 'Ein neu erfasster Zugang gilt erst als verbunden, wenn er eingerichtet ist.', + }, + { + key: 'usage', + label: 'Wofür wird das System genutzt?', + type: 'multiline', + required: true, + }, + { + key: 'permissionNote', + label: 'Berechtigungshinweis', + placeholder: 'z. B. Schreibt erst nach Freigabe', + }, +] + // ── Eine Systemzeile ────────────────────────────────────────────────────────── +interface RowProps { + system: AgentSystem + busy: boolean + editing: boolean + onToggle: (id: string, enabled: boolean) => void + onEdit: (id: string) => void + onDelete: (id: string) => void +} + /** * `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 SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onEdit, onDelete }: RowProps) { const look = ACCESS_APPEARANCE[system.access] const AccessIcon = look.icon + const aktiv = istAktiv(system) + const label = AGENT_SYSTEM_LABELS[system.type] ?? system.type return ( - {/* Kopfzeile: System, Zugriffstyp, Verbindungsstatus */} + {/* Kopfzeile: System, Aktivzustand, Zugriffstyp, Verbindungsstatus */} - {AGENT_SYSTEM_LABELS[system.type] ?? system.type} + {label} - + + + {editing && ( + <> + + onEdit(system.id)} sx={{ color: DS_TEXT.secondary }}> + + + + + onDelete(system.id)} sx={{ color: DS_TEXT.secondary }}> + + + + + )} @@ -109,94 +199,132 @@ const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) { {/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */} - - - - - Berechtigung:{' '} - - {system.permissionNote} + {system.permissionNote && ( + + + + + Berechtigung:{' '} + + {system.permissionNote} + + + )} + + + onToggle(system.id, checked)} + slotProps={{ input: { 'aria-label': `Systemzugang ${label} aktivieren` } }} + /> + + {aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'} ) }) -// ── 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 { mutate: saveSystems, isPending } = useSaveAgentSystems() - 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]) + /** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Zugangs. */ + const [bearbeitet, setBearbeitet] = useState(null) + const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false) + + const systems = agent.systems + + /** + * Anders als bei den Aufgaben wirkt hier jede Änderung sofort — ein + * Systemzugang ist ein Ein/Aus-Entscheid wie beim Kanalreiter, kein Formular + * mit Zwischenstand. Zwei Speicherwege nebeneinander wären zwei Wahrheiten. + */ + const speichern = useCallback( + (liste: AgentSystem[]) => saveSystems({ id: agent.id, systems: liste }), + [saveSystems, agent.id], + ) + + const handleToggle = useCallback( + (id: string, enabled: boolean) => { + speichern(systems.map((s) => (s.id === id ? { ...s, enabled } : s))) + }, + [speichern, systems], + ) + + const handleDelete = useCallback( + (id: string) => speichern(removeById(systems, id)), + [speichern, systems], + ) + + const handleDialogSave = useCallback( + (werte: EntityDraft, active: boolean) => { + const bestehend = bearbeitet !== 'new' ? systems.find((s) => s.id === bearbeitet) : undefined + const eintrag: AgentSystem = { + id: bestehend?.id ?? newEntityId(agent.id, 'system'), + type: werte.type as AgentSystemType, + access: werte.access as AgentAccessLevel, + status: werte.status as AgentConnectionStatus, + usage: werte.usage.trim(), + permissionNote: werte.permissionNote?.trim() ?? '', + enabled: active, + } + speichern(upsertById(systems, eintrag)) + setBearbeitet(null) + }, + [bearbeitet, systems, agent.id, speichern], + ) + + const inBearbeitung = bearbeitet && bearbeitet !== 'new' + ? systems.find((s) => s.id === bearbeitet) + : undefined + + const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } } 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». + + + + Diese Systeme und Datenquellen darf {agent.name} verwenden. Ein abgeschalteter Zugang wird nicht + mehr abgefragt. + + + + + - {agent.systems.length === 0 ? ( + {systems.length === 0 ? ( ) : ( - <> - - - + + {systems.map((system) => ( + + ))} + )} + + setBearbeitet(null)} + /> ) } diff --git a/src/components/team/AgentTasksTab.tsx b/src/components/team/AgentTasksTab.tsx index ff3bde3..514d547 100644 --- a/src/components/team/AgentTasksTab.tsx +++ b/src/components/team/AgentTasksTab.tsx @@ -9,11 +9,14 @@ */ import { memo, useCallback, useMemo, useState } from 'react' -import { Box, Button, Switch, Typography } from '@mui/material' -import { RotateCcw, Save, ShieldCheck } from 'lucide-react' +import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material' +import { Pencil, Plus, RotateCcw, Save, ShieldCheck, Trash2 } from 'lucide-react' import type { AgentTask, TeamAgent } from '../../domain/teamAgent' import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { GenericBadge } from '../shared/GenericBadge' +import { AgentEntityDialog } from './AgentEntityDialog' +import type { EntityDraft, EntityField } from './AgentEntityDialog' +import { newEntityId, removeById, upsertById } from './agentEntityEditing' import { useSaveAgentTasks } from '../../hooks/useTeamAgents' // ── Eine Aufgabenzeile ──────────────────────────────────────────────────────── @@ -22,13 +25,17 @@ interface TaskRowProps { task: AgentTask busy: boolean onToggle: (id: string, enabled: boolean) => void + /** Im Bearbeitungsmodus erscheinen Stift und Papierkorb an der Zeile (§2A). */ + editing: boolean + onEdit: (id: string) => void + onDelete: (id: string) => 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 TaskRow = memo(function TaskRow({ task, busy, onToggle, editing, onEdit, onDelete }: TaskRowProps) { const handleChange = useCallback( (_event: unknown, checked: boolean) => onToggle(task.id, checked), [onToggle, task.id], @@ -71,9 +78,26 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) { - - {task.title} - + + + {task.title} + + + {editing && ( + + + onEdit(task.id)} sx={{ color: DS_TEXT.secondary }}> + + + + + onDelete(task.id)} sx={{ color: DS_TEXT.secondary }}> + + + + + )} + {/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle Wortlaut steht am Element. */} @@ -111,10 +135,27 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) { // ── Register «Aufgaben» ─────────────────────────────────────────────────────── +/** Die Felder der Aufgabenmaske (Runde 10, §2A). */ +const TASK_FIELDS: EntityField[] = [ + { key: 'title', label: 'Aufgabentitel', required: true }, + { key: 'description', label: 'Aufgabenbeschreibung', type: 'multiline', required: true }, + { + key: 'schedule', + label: 'Auslöser oder Rhythmus', + placeholder: 'z. B. täglich 06:00 oder bei Eingang', + helperText: 'Wann die Aufgabe läuft — steht so auch im Personalblatt.', + }, +] + export function AgentTasksTab({ agent }: { agent: TeamAgent }) { const [draft, setDraft] = useState(agent.tasks) const saveTasks = useSaveAgentTasks() + /** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Eintrags. */ + const [bearbeitet, setBearbeitet] = useState(null) + /** Zeigt die Bearbeiten- und Löschknöpfe an den Zeilen. */ + const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false) + /** * Beim Wechsel des Mitarbeiters wird der Entwurf neu aufgesetzt — sonst stünde * der Entwurf des zuvor gewählten Mitarbeiters in einem fremden Personalblatt. @@ -147,6 +188,40 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) { saveTasks.mutate({ id: agent.id, tasks: draft }) }, [saveTasks, agent.id, draft]) + const handleDelete = useCallback((id: string) => { + setDraft(prev => removeById(prev, id)) + }, []) + + /** + * Anlegen und Bearbeiten teilen sich die Maske und diesen Griff. + * + * Der Eintrag landet im Entwurf und nicht direkt im Bestand — gespeichert + * wird wie bisher bewusst erst über «Änderungen speichern». Zwei + * Speicherwege für dieselbe Liste wären zwei Wahrheiten darüber, was gerade + * gilt. + */ + const handleDialogSave = useCallback((werte: EntityDraft, active: boolean) => { + setDraft(prev => { + const bestehend = bearbeitet !== 'new' ? prev.find(t => t.id === bearbeitet) : undefined + const eintrag: AgentTask = { + ...(bestehend ?? { + id: newEntityId(agent.id, 'task'), + requiresApproval: false, + } as AgentTask), + title: werte.title.trim(), + description: werte.description.trim(), + schedule: werte.schedule?.trim() || 'auf Auslösung', + enabled: active, + } + return upsertById(prev, eintrag) + }) + setBearbeitet(null) + }, [bearbeitet, agent.id]) + + const inBearbeitung = bearbeitet && bearbeitet !== 'new' + ? draft.find(t => t.id === bearbeitet) + : undefined + const busy = saveTasks.isPending const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } } @@ -160,6 +235,28 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) { Speichern für {agent.name} wirksam. + {/* Auf Höhe der Überschrift, im Stil des bestehenden «Konfiguration öffnen» (§2A). */} + + + + + {dirty && ( {draft.map((task) => ( - + ))} )} + + setBearbeitet(null)} + /> ) } diff --git a/src/components/team/__tests__/agentEntityEditing.test.ts b/src/components/team/__tests__/agentEntityEditing.test.ts new file mode 100644 index 0000000..4dd6156 --- /dev/null +++ b/src/components/team/__tests__/agentEntityEditing.test.ts @@ -0,0 +1,67 @@ +/** + * Die drei Listengriffe der Agentenverwaltung (Runde 10, §2). + * + * Geprüft wird, was in den Reitern Aufgaben, Systeme und Kanäle täglich + * passiert: einen Eintrag ändern, einen anlegen, einen entfernen — und dass + * die übrige Reihenfolge dabei unberührt bleibt. + */ + +import { describe, expect, it } from 'vitest' +import { newEntityId, removeById, upsertById } from '../agentEntityEditing' + +interface Eintrag { + id: string + titel: string +} + +const liste: Eintrag[] = [ + { id: 'a', titel: 'Erster' }, + { id: 'b', titel: 'Zweiter' }, + { id: 'c', titel: 'Dritter' }, +] + +describe('upsertById', () => { + it('ersetzt einen bestehenden Eintrag an Ort und Stelle', () => { + const ergebnis = upsertById(liste, { id: 'b', titel: 'Geändert' }) + + expect(ergebnis.map((e) => e.id)).toEqual(['a', 'b', 'c']) + expect(ergebnis[1].titel).toBe('Geändert') + }) + + it('hängt einen neuen Eintrag ans Ende', () => { + const ergebnis = upsertById(liste, { id: 'd', titel: 'Vierter' }) + + expect(ergebnis.map((e) => e.id)).toEqual(['a', 'b', 'c', 'd']) + }) + + it('lässt die übergebene Liste unverändert', () => { + upsertById(liste, { id: 'b', titel: 'Geändert' }) + + expect(liste[1].titel).toBe('Zweiter') + expect(liste).toHaveLength(3) + }) +}) + +describe('removeById', () => { + it('entfernt genau einen Eintrag', () => { + expect(removeById(liste, 'b').map((e) => e.id)).toEqual(['a', 'c']) + }) + + it('lässt eine Liste ohne Treffer unverändert', () => { + expect(removeById(liste, 'unbekannt').map((e) => e.id)).toEqual(['a', 'b', 'c']) + }) +}) + +describe('newEntityId', () => { + it('stellt Agent und Art voran, damit die Kennung zuordenbar bleibt', () => { + expect(newEntityId('livia', 'task')).toMatch(/^livia-task-/) + }) + + it('vergibt für jeden Aufruf eine eigene Kennung', () => { + const kennungen = new Set( + Array.from({ length: 50 }, () => newEntityId('nora', 'system')), + ) + + expect(kennungen.size).toBe(50) + }) +}) diff --git a/src/components/team/agentEntityEditing.ts b/src/components/team/agentEntityEditing.ts new file mode 100644 index 0000000..bbcdc70 --- /dev/null +++ b/src/components/team/agentEntityEditing.ts @@ -0,0 +1,47 @@ +/** + * Property On — die gemeinsamen Listenoperationen der Agentenverwaltung + * (Runde 10, §2). + * + * Aufgaben, Systeme und Kanäle werden alle drei als Liste geführt und gleich + * bearbeitet: einen Eintrag ändern, einen anlegen, einen entfernen. Diese drei + * Griffe stehen deshalb einmal hier statt dreimal in den Reitern — sonst + * unterscheiden sie sich nach der ersten Korrektur. + * + * Bewusst reine Funktionen ohne React: Sie lassen sich so ohne Renderer prüfen, + * und die Reiter behalten die Hoheit über ihren Entwurf. + */ + +interface MitId { id: string } + +/** + * Eintrag ersetzen oder anhängen. + * + * Neue Einträge kommen ans Ende und nicht an den Anfang: Wer etwas anlegt, + * sucht es dort, wo die Liste weitergeht — und die gewachsene Reihenfolge der + * bestehenden Einträge bleibt unangetastet. + */ +export function upsertById(list: T[], eintrag: T): T[] { + const idx = list.findIndex(e => e.id === eintrag.id) + if (idx === -1) return [...list, eintrag] + const kopie = [...list] + kopie[idx] = eintrag + return kopie +} + +export function removeById(list: T[], id: string): T[] { + return list.filter(e => e.id !== id) +} + +/** + * Kennung für einen neu angelegten Eintrag. + * + * Präfix aus Agent und Entität, damit man einer Kennung im Zweifel ansieht, + * wohin sie gehört. `crypto.randomUUID` ist in allen Zielbrowsern vorhanden; + * der Zeitstempel-Rückfall greift nur in Testumgebungen ohne Web-Crypto. + */ +export function newEntityId(agentId: string, art: string): string { + const zufall = typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID().slice(0, 8) + : Date.now().toString(36) + return `${agentId}-${art}-${zufall}` +} diff --git a/src/domain/teamAgent.ts b/src/domain/teamAgent.ts index 4fb55e4..09404a7 100644 --- a/src/domain/teamAgent.ts +++ b/src/domain/teamAgent.ts @@ -115,6 +115,11 @@ export interface AgentSystem { type: AgentSystemType access: AgentAccessLevel status: AgentConnectionStatus + /** + * Ob der Mitarbeiter diesen Zugang derzeit nutzt. Optional, weil der Katalog + * die Zugänge ohne Schalter führt — fehlt das Feld, gilt der Zugang als aktiv. + */ + enabled?: boolean /** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */ usage: string /** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */ @@ -242,5 +247,7 @@ export interface UpdateTeamAgentInput { status?: AgentStatus tasks?: AgentTask[] channels?: AgentChannel[] + /** Systeme sind seit Runde 10 (§2) genauso bearbeitbar wie Aufgaben und Kanäle. */ + systems?: AgentSystem[] settings?: AgentSetting[] } diff --git a/src/hooks/useTeamAgents.ts b/src/hooks/useTeamAgents.ts index a0c5e01..43470de 100644 --- a/src/hooks/useTeamAgents.ts +++ b/src/hooks/useTeamAgents.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { teamAgentService } from '../services/teamAgentService' -import type { AgentTask, AgentChannel, AgentSetting } from '../domain/teamAgent' +import type { AgentTask, AgentChannel, AgentSetting, AgentSystem } from '../domain/teamAgent' import { useToastStore } from '../stores/toastStore' import { STALE_TEAM_AGENTS } from '../lib/constants' import { QK_AGENT_PROTOCOL } from './useAgentProtocol' @@ -100,3 +100,24 @@ export function useSaveAgentChannels() { }, }) } + +/** + * Systeme speichern (Runde 10, §2). + * + * Bewusst dieselbe Bauart wie `useSaveAgentChannels`: Anlegen, Bearbeiten und + * Löschen schreiben allesamt die vollständige Liste zurück. Ein Endpunkt je + * Einzeloperation wäre bei einem Mockup-Provider mehr Schnittstelle als Nutzen. + */ +export function useSaveAgentSystems() { + const invalidate = useAgentMutationInvalidation() + return useMutation({ + mutationFn: ({ id, systems }: { id: string; systems: AgentSystem[] }) => + teamAgentService.saveSystems(id, systems), + onSuccess: () => { + invalidate() + }, + onError: () => { + useToastStore.getState().showToast('System konnte nicht geändert werden.', 'error') + }, + }) +} diff --git a/src/lib/agentWorkspaces.ts b/src/lib/agentWorkspaces.ts index b4bd6a8..b46bb3c 100644 --- a/src/lib/agentWorkspaces.ts +++ b/src/lib/agentWorkspaces.ts @@ -48,6 +48,23 @@ export function agentWorkspaceById(id: string): AgentWorkspace | undefined { export const AGENT_SUPERVISOR: AgentWorkspace = { id: 'thomas', name: 'Thomas', - role: 'Personalverwalter', + role: 'Agentenverwalter', path: ROUTES.SUPPLY.TEAM, } + +/** + * Die Einträge unter «Meine Agenten» in der Navigation (Runde 10, §1). + * + * Thomas zuoberst, darunter die fünf in Arbeitsablauf-Reihenfolge. Bewusst eine + * eigene Liste und keine Erweiterung von `AGENT_WORKSPACES`: Sonst erschiene er + * als sechster Mitarbeitender im Meetingraum, in der Auslastungsanzeige und im + * Willkommensdialog. Navigation und Belegschaft sind zwei verschiedene Fragen + * an dieselben Daten — und Thomas gehört nur in die erste. + * + * Sein Ziel ist dieselbe Route wie aus dem Sitzungszimmer; es gibt ihn genau + * einmal. + */ +export const AGENT_NAV_ENTRIES: AgentWorkspace[] = [ + AGENT_SUPERVISOR, + ...AGENT_WORKSPACES, +] diff --git a/src/provider/MockupTeamAgentProvider.ts b/src/provider/MockupTeamAgentProvider.ts index 9304861..a639955 100644 --- a/src/provider/MockupTeamAgentProvider.ts +++ b/src/provider/MockupTeamAgentProvider.ts @@ -44,6 +44,7 @@ export const MockupTeamAgentProvider: ITeamAgentProvider = { ...(data.status !== undefined ? { status: data.status } : {}), ...(data.tasks ? { tasks: data.tasks } : {}), ...(data.channels ? { channels: data.channels } : {}), + ...(data.systems ? { systems: data.systems } : {}), ...(data.settings ? { settings: data.settings } : {}), } save() diff --git a/src/services/teamAgentService.ts b/src/services/teamAgentService.ts index 1a69211..3254735 100644 --- a/src/services/teamAgentService.ts +++ b/src/services/teamAgentService.ts @@ -1,5 +1,5 @@ import { MockupTeamAgentProvider } from '../provider/MockupTeamAgentProvider' -import type { TeamAgent, AgentTask, AgentChannel, AgentSetting } from '../domain/teamAgent' +import type { TeamAgent, AgentTask, AgentChannel, AgentSetting, AgentSystem } from '../domain/teamAgent' import { AgentStatus } from '../domain/teamAgent' import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol' import type { ListResponse, ItemResponse } from './types' @@ -95,4 +95,13 @@ export const teamAgentService = { throwServiceError(err) } }, -} + /** Systeme speichern — Runde 10, §2: gleiche Mechanik wie Aufgaben und Kanäle. */ + async saveSystems(id: string, systems: AgentSystem[]): Promise> { + try { + const data = await provider.update(id, { systems }) + return { data } + } catch (err) { + throwServiceError(err) + } + }, +} \ No newline at end of file