feat(agenten): Runde 10 Prompt 1 — gemeinsame Bearbeitung für Aufgaben, Systeme und Kanäle

Aufgaben, Systeme und Kanäle werden alle drei als Liste geführt und ab jetzt
auch gleich bearbeitet: anlegen, ändern, entfernen, zu- und abschalten — dort,
wo man sie sieht. Die Maske und die drei Listengriffe stehen einmal zentral
statt dreimal je Reiter; sonst unterscheiden sie sich nach der ersten Korrektur.

- AgentEntityDialog: eine Maske für alle drei Entitäten, über den React-
  Schlüssel je Eintrag neu aufgebaut statt über einen Effekt zurückgesetzt
- agentEntityEditing: upsertById, removeById, newEntityId als reine Funktionen
- Systeme sind über Provider, Service und Hook schreibbar (saveSystems) und
  tragen neu einen Aktiv-Schalter
- Systemreiter: eine einzige Liste statt «Lesende»/«Schreibende Systemzugänge»,
  kein Einleitungsbalken mehr; Aktiv, Zugriff und Verbindung stehen je Eintrag
- Thomas steht als «Agentenverwalter» in der Navigation, zählt aber nicht als
  sechster Agent in Sitzungszimmer, Auslastung und Begrüssung
- Chatverlauf scrollt in sich, ohne die Seite springen zu lassen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-09-12 22:41:34 +02:00
1 parent c6f3b2d6e3
commit 1c4b3f7dd8
15 files changed
+951 -130

No files matched your search

+3 -3
View File
@@ -5,7 +5,7 @@ import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { AgentAvatar } from '../team' 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 { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds' import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
@@ -25,7 +25,7 @@ const ROLE_LABELS: Record<string, string> = {
* rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen, * rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen,
* die vorher im Profilmenü lagen. * 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, * Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen,
* Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24, * Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24,
* Schritt 7). * Schritt 7).
@@ -116,7 +116,7 @@ export function GlobalNavMenu() {
<MenuSection> <MenuSection>
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} /> <MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
{/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */} {/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */}
{AGENT_WORKSPACES.map(agent => ( {AGENT_NAV_ENTRIES.map(agent => (
<SubItem key={agent.id} onClick={() => go(agent.path)}> <SubItem key={agent.id} onClick={() => go(agent.path)}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}> <Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
{agent.name} {agent.name}
@@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest'
import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig' import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig'
import { WorkspaceType } from '../../../domain/enums' import { WorkspaceType } from '../../../domain/enums'
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants' 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 const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems
@@ -47,9 +47,9 @@ describe('Navigationseintrag «Meine Agenten»', () => {
expect(agenten?.path).toBe(ROUTES.SUPPLY.TEAM) 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) 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', () => { 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', () => { it('verweist mit jedem Agenten auf eine eigene Seite', () => {
const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL)
const paths = agenten?.children?.map(c => c.path) ?? [] 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) expect(new Set(paths).size).toBe(paths.length)
}) })
+2 -2
View File
@@ -1,6 +1,6 @@
import { WorkspaceType } from '../../domain/enums' import { WorkspaceType } from '../../domain/enums'
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants' 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 type { LucideIcon } from 'lucide-react'
import { import {
Building2, Building2,
@@ -75,7 +75,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
icon: Users, icon: Users,
// Die drei Verwaltungsbereiche sind Reiter der Hauptseite geworden; // Die drei Verwaltungsbereiche sind Reiter der Hauptseite geworden;
// hier stehen ab Runde 4 die fünf digitalen Mitarbeitenden selbst. // 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, path: a.path,
label: a.name, label: a.name,
agentId: a.id, agentId: a.id,
+165 -7
View File
@@ -12,14 +12,21 @@
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { import {
Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
FormControlLabel, Switch, TextField, Typography, FormControlLabel, IconButton, Switch, TextField, Tooltip, Typography,
} from '@mui/material' } 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 { LucideIcon } from 'lucide-react'
import type { AgentChannel, TeamAgent } from '../../domain/teamAgent' 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 { 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 { useSaveAgentChannels } from '../../hooks/useTeamAgents'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
@@ -40,15 +47,65 @@ const TEST_RESULT_BY_STATUS: Record<AgentConnectionStatus, TestResult> = {
[AgentConnectionStatus.ROADMAP]: { ok: false, message: 'Verbindung fehlgeschlagen. Dieser Kanal ist erst geplant und noch nicht eingerichtet — ein Test ist nach der Einrichtung möglich.' }, [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 ──────────────────────────────────────────────────────────────── // ── Kanalzeile ────────────────────────────────────────────────────────────────
interface RowProps { interface RowProps {
channel: AgentChannel channel: AgentChannel
editing: boolean
onToggle: (channelId: string, enabled: boolean) => void onToggle: (channelId: string, enabled: boolean) => void
onConfigure: (channelId: string) => 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 label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type
const DirectionIcon = DIRECTION_ICON[channel.direction] const DirectionIcon = DIRECTION_ICON[channel.direction]
@@ -71,6 +128,20 @@ const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }:
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}> <Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction} {AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction}
</Typography> </Typography>
{editing && (
<>
<Tooltip title="Kanal bearbeiten">
<IconButton size="small" onClick={() => onEdit(channel.id)} sx={{ color: DS_TEXT.secondary }}>
<Pencil size={14} />
</IconButton>
</Tooltip>
<Tooltip title="Kanal entfernen">
<IconButton size="small" onClick={() => onDelete(channel.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</>
)}
</Box> </Box>
</Box> </Box>
@@ -197,6 +268,10 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
const { mutate: saveChannels } = useSaveAgentChannels() const { mutate: saveChannels } = useSaveAgentChannels()
const [openChannelId, setOpenChannelId] = useState<string | null>(null) const [openChannelId, setOpenChannelId] = useState<string | null>(null)
/** `null` = Maske zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Kanals. */
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
const channels = agent.channels const channels = agent.channels
// Beide Kennzahlen in einem Durchlauf (§10.4). // 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 handleConfigure = useCallback((channelId: string) => setOpenChannelId(channelId), [])
const handleClose = useCallback(() => setOpenChannelId(null), []) 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 ( return (
<Box> <Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}> <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus. Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus.
</Typography> </Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25, mb: 1.5 }}> <Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25 }}>
{summary.active} von {channels.length} Kanälen aktiv · {summary.connected} verbunden {summary.active} von {channels.length} Kanälen aktiv · {summary.connected} verbunden
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5, mb: 1.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<Pencil size={14} aria-hidden />}
onClick={() => setBearbeitungsmodus((v) => !v)}
sx={{ textTransform: 'none', ...FOCUS_RING }}
>
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Kanäle bearbeiten'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Plus size={14} aria-hidden />}
onClick={() => setBearbeitet('new')}
sx={{ textTransform: 'none', ...FOCUS_RING }}
>
Neuen Kanal anbinden
</Button>
</Box>
{channels.length === 0 ? ( {channels.length === 0 ? (
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}> <Typography variant="body2" sx={{ color: DS_TEXT.muted }}>
Für {agent.name} ist noch kein Kanal hinterlegt. Für {agent.name} ist noch kein Kanal hinterlegt.
@@ -238,12 +370,38 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
) : ( ) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 1.25 }}> <Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 1.25 }}>
{channels.map((channel) => ( {channels.map((channel) => (
<ChannelRow key={channel.id} channel={channel} onToggle={handleToggle} onConfigure={handleConfigure} /> <ChannelRow
key={channel.id}
channel={channel}
editing={bearbeitungsmodus}
onToggle={handleToggle}
onConfigure={handleConfigure}
onEdit={setBearbeitet}
onDelete={handleDelete}
/>
))} ))}
</Box> </Box>
)} )}
<ChannelConfigDialog channel={openChannel} onClose={handleClose} /> <ChannelConfigDialog channel={openChannel} onClose={handleClose} />
<AgentEntityDialog
open={bearbeitet !== null}
entityKey={bearbeitet ?? undefined}
title={inBearbeitung ? 'Kanal bearbeiten' : 'Neuen Kanal anbinden'}
fields={CHANNEL_FIELDS}
initial={{
type: inBearbeitung?.type ?? AgentChannelType.EMAIL,
direction: inBearbeitung?.direction ?? AgentChannelDirection.BOTH,
status: inBearbeitung?.status ?? AgentConnectionStatus.DISCONNECTED,
description: inBearbeitung?.description ?? '',
displayName: inBearbeitung?.config.displayName ?? '',
}}
activeLabel="Kanal aktiv"
initialActive={inBearbeitung?.enabled ?? true}
onSave={handleDialogSave}
onClose={() => setBearbeitet(null)}
/>
</Box> </Box>
) )
} }
+69 -2
View File
@@ -1,8 +1,18 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { useAgentChatStore } from '../../stores/agentChatStore' import { useAgentChatStore } from '../../stores/agentChatStore'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { DS_BORDER, DS_SLATE, DS_TEXT } from '../../lib/ds' 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). * Der Gesprächsverlauf unter dem Eingabefeld (Runde 9, §3).
* *
@@ -19,13 +29,70 @@ export function AgentChatTranscript() {
const messages = useAgentChatStore(s => s.messages) const messages = useAgentChatStore(s => s.messages)
const agent = useAgentChatStore(s => s.agent) const agent = useAgentChatStore(s => s.agent)
const containerRef = useRef<HTMLDivElement>(null)
/** Die zuletzt gestellte Frage — an ihr richtet sich die Scrollposition aus. */
const lastQuestionRef = useRef<HTMLDivElement>(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 if (messages.length === 0 || !agent) return null
const letzteFrageIndex = messages.map(m => m.from).lastIndexOf('user')
return ( return (
<Box sx={{ width: '100%', maxWidth: 720, mt: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box
ref={containerRef}
sx={{
width: '100%',
maxWidth: 720,
mt: 2.5,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
// Der Verlauf scrollt für sich; die Seite darunter bleibt, wo sie ist.
maxHeight: TRANSCRIPT_MAX_HEIGHT,
overflowY: 'auto',
// Ohne diesen Innenabstand klebt die Bildlaufleiste am Text.
pr: 0.5,
// Verhindert, dass das Scrollen am Ende des Verlaufs auf die Seite
// durchschlägt — sonst wandert der Bildschirm doch wieder.
overscrollBehavior: 'contain',
}}
>
{messages.map((m, i) => ( {messages.map((m, i) => (
m.from === 'user' ? ( m.from === 'user' ? (
<Box key={i} sx={{ alignSelf: 'flex-end', maxWidth: '85%' }}> <Box
key={i}
ref={i === letzteFrageIndex ? lastQuestionRef : undefined}
sx={{ alignSelf: 'flex-end', maxWidth: '85%' }}
>
<Box <Box
sx={{ sx={{
px: 1.75, py: 1, px: 1.75, py: 1,
+151
View File
@@ -0,0 +1,151 @@
import { useState } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
FormControlLabel, MenuItem, Switch, TextField,
} from '@mui/material'
import { DS_BRAND } from '../../lib/ds'
/**
* Ein Feld der Bearbeitungsmaske.
*
* Bewusst ein winziger Beschreibungstyp statt einer Maske je Entität: Aufgaben,
* Systeme und Kanäle unterscheiden sich nur in den Feldern, nicht im Verhalten.
* Drei fast gleiche Dialoge wären drei Stellen für jede künftige Korrektur.
*/
export interface EntityField {
key: string
label: string
/** `select` braucht `options`, `multiline` wird mehrzeilig dargestellt. */
type?: 'text' | 'multiline' | 'select' | 'url'
options?: { value: string; label: string }[]
required?: boolean
placeholder?: string
helperText?: string
}
export type EntityDraft = Record<string, string>
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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>{title}</DialogTitle>
{open && (
<DialogBody
key={entityKey ?? 'neu'}
fields={fields}
initial={initial}
activeLabel={activeLabel}
initialActive={initialActive}
onSave={onSave}
onClose={onClose}
/>
)}
</Dialog>
)
}
function DialogBody({
fields, initial, activeLabel, initialActive, onSave, onClose,
}: Omit<Props, 'open' | 'title' | 'entityKey'>) {
const [draft, setDraft] = useState<EntityDraft>(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 (
<>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
{fields.map(f => (
<TextField
key={f.key}
select={f.type === 'select'}
multiline={f.type === 'multiline'}
minRows={f.type === 'multiline' ? 3 : undefined}
type={f.type === 'url' ? 'url' : 'text'}
label={f.label}
value={draft[f.key] ?? ''}
onChange={e => 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 => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
{o.label}
</MenuItem>
))}
</TextField>
))}
{activeLabel && (
<FormControlLabel
control={<Switch size="small" checked={active} onChange={(_, v) => setActive(v)} />}
label={active ? activeLabel : `${activeLabel} — derzeit inaktiv`}
slotProps={{ typography: { variant: 'body2' } }}
/>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} sx={{ textTransform: 'none' }}>
Abbrechen
</Button>
<Button
variant="contained"
onClick={handleSave}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Speichern
</Button>
</DialogActions>
</>
)
}
+257 -102
View File
@@ -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 * Zeigt und pflegt die Fachsysteme und Datenquellen eines digitalen
* Mitarbeiter verwenden darf. Keine Aktionen — Zugänge werden auf der Seite * Mitarbeiters. Seit Runde 10 ist der Reiter nicht mehr nur Anzeige: Zugänge
* «Kanäle & Systeme» eingerichtet, nicht hier. * lassen sich hier anlegen, ändern, zu- und abschalten — dort, wo man sie sieht.
* *
* Schreibrechte sind der einzige wirklich folgenreiche Zustand auf diesem * Bewusst eine einzige Liste statt getrennter Gruppen «lesend» und
* Reiter (§10.4): sie verändern Kundendaten in der Bewirtschaftung. Sie stehen * «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser
* deshalb in einer eigenen Gruppe und tragen zusätzlich zur hervorgehobenen * Mitarbeiter», nicht «wie viele davon sind lesend». Der Zugriffstyp steht
* Fläche ein Icon UND einen Klartextsatz — Farbe allein ist kein zulässiger * weiterhin an jedem Eintrag — als Badge, als Icon und als Klartextsatz, denn
* Statusträger (§2.3, §18). * Farbe allein ist kein zulässiger Statusträger (§2.3, §18).
*/ */
import { memo, useMemo } from 'react' import { memo, useCallback, useState } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material'
import { Eye, KeyRound, PenLine, ShieldCheck } from 'lucide-react' import { Eye, KeyRound, PenLine, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react'
import type { LucideIcon } 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 type { AgentSystem, TeamAgent } from '../../domain/teamAgent'
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges' import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
import { DetailSectionTitle } from './WorkItemDetailSections' import { GenericBadge } from '../shared/GenericBadge'
import { AGENT_SYSTEM_LABELS } from '../../lib/constants' 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' import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
// ── Darstellung je Zugriffstyp ──────────────────────────────────────────────── // ── Darstellung je Zugriffstyp ────────────────────────────────────────────────
@@ -58,22 +70,79 @@ const ACCESS_APPEARANCE: Record<AgentAccessLevel, AccessAppearance> = {
}, },
} }
const GROUP_TITLE_READ = 'Lesende Systemzugänge' /** Ohne Feld gilt ein Zugang als aktiv — der Katalog führt die Zugänge ohne Schalter. */
const GROUP_TITLE_WRITE = 'Schreibende Systemzugänge' function istAktiv(system: AgentSystem): boolean {
return system.enabled !== false
function systemCount(count: number): string {
return count === 1 ? '1 Fachsystem' : `${count} Fachsysteme`
} }
// ── 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 ────────────────────────────────────────────────────────── // ── 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 * `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). * 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 look = ACCESS_APPEARANCE[system.access]
const AccessIcon = look.icon const AccessIcon = look.icon
const aktiv = istAktiv(system)
const label = AGENT_SYSTEM_LABELS[system.type] ?? system.type
return ( return (
<Box <Box
@@ -81,21 +150,42 @@ const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) {
sx={{ sx={{
border: `1px solid ${look.border}`, border: `1px solid ${look.border}`,
borderRadius: 2, borderRadius: 2,
bgcolor: look.bg, // Ein abgeschalteter Zugang tritt zurück, bleibt aber vollständig lesbar.
bgcolor: aktiv ? look.bg : DS_BG.subtle,
opacity: aktiv ? 1 : 0.75,
p: 1.75, p: 1.75,
display: 'grid', display: 'grid',
gap: 0.875, gap: 0.875,
}} }}
> >
{/* Kopfzeile: System, Zugriffstyp, Verbindungsstatus */} {/* Kopfzeile: System, Aktivzustand, Zugriffstyp, Verbindungsstatus */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} /> <AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} />
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}> <Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{AGENT_SYSTEM_LABELS[system.type] ?? system.type} {label}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto' }}> <Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto', alignItems: 'center' }}>
<GenericBadge
label={aktiv ? 'Aktiv' : 'Inaktiv'}
semanticVariant={aktiv ? 'confidenceHigh' : 'muted'}
bold
/>
<AgentAccessBadge access={system.access} /> <AgentAccessBadge access={system.access} />
<AgentConnectionStatusBadge status={system.status} /> <AgentConnectionStatusBadge status={system.status} />
{editing && (
<>
<Tooltip title="System bearbeiten">
<IconButton size="small" onClick={() => onEdit(system.id)} sx={{ color: DS_TEXT.secondary }}>
<Pencil size={14} />
</IconButton>
</Tooltip>
<Tooltip title="System entfernen">
<IconButton size="small" onClick={() => onDelete(system.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</>
)}
</Box> </Box>
</Box> </Box>
@@ -109,94 +199,132 @@ const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) {
</Typography> </Typography>
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */} {/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */}
<Box {system.permissionNote && (
sx={{ <Box
display: 'flex', sx={{
gap: 0.75, display: 'flex',
alignItems: 'flex-start', gap: 0.75,
p: 1, alignItems: 'flex-start',
borderRadius: 1.5, p: 1,
bgcolor: DS_BG.surface, borderRadius: 1.5,
border: `1px solid ${DS_BORDER.default}`, bgcolor: DS_BG.surface,
}} border: `1px solid ${DS_BORDER.default}`,
> }}
<ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} /> >
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}> <ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
<Box component="span" sx={{ fontWeight: 700 }}> <Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
Berechtigung:{' '} <Box component="span" sx={{ fontWeight: 700 }}>
</Box> Berechtigung:{' '}
{system.permissionNote} </Box>
{system.permissionNote}
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Switch
size="small"
checked={aktiv}
disabled={busy}
onChange={(_, checked) => onToggle(system.id, checked)}
slotProps={{ input: { 'aria-label': `Systemzugang ${label} aktivieren` } }}
/>
<Typography variant="caption" sx={{ color: aktiv ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 600 }}>
{aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
) )
}) })
// ── Eine Gruppe ───────────────────────────────────────────────────────────────
function SystemGroup({ title, systems }: { title: string; systems: AgentSystem[] }) {
if (systems.length === 0) return null
return (
<Box component="section" aria-label={title}>
<DetailSectionTitle>{title}</DetailSectionTitle>
<Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
{systems.map((system) => (
<SystemRow key={system.id} system={system} />
))}
</Box>
</Box>
)
}
// ── Reiter ──────────────────────────────────────────────────────────────────── // ── Reiter ────────────────────────────────────────────────────────────────────
export function AgentSystemsTab({ agent }: { agent: TeamAgent }) { export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
const { reading, writing } = useMemo(() => { const { mutate: saveSystems, isPending } = useSaveAgentSystems()
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(() => { /** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Zugangs. */
const name = `«${agent.name}»` const [bearbeitet, setBearbeitet] = useState<string | null>(null)
if (reading.length > 0 && writing.length > 0) { const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
return `${name} liest ${systemCount(reading.length)} und schreibt nach Freigabe in ${systemCount(writing.length)}.`
} const systems = agent.systems
if (reading.length > 0) {
return `${name} liest ${systemCount(reading.length)} und schreibt in keines davon.` /**
} * Anders als bei den Aufgaben wirkt hier jede Änderung sofort — ein
if (writing.length > 0) { * Systemzugang ist ein Ein/Aus-Entscheid wie beim Kanalreiter, kein Formular
return `${name} schreibt nach Freigabe in ${systemCount(writing.length)}; weitere Zugänge bestehen nicht.` * mit Zwischenstand. Zwei Speicherwege nebeneinander wären zwei Wahrheiten.
} */
return `Für ${name} ist bisher kein Systemzugang hinterlegt.` const speichern = useCallback(
}, [agent.name, reading.length, writing.length]) (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 ( return (
<Box sx={{ display: 'grid', gap: 2.5 }}> <Box sx={{ display: 'grid', gap: 2 }}>
{/* Einordnung — dieser Reiter zeigt den Stand, er verändert ihn nicht */} <Box>
<Box <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
sx={{ Diese Systeme und Datenquellen darf {agent.name} verwenden. Ein abgeschalteter Zugang wird nicht
border: `1px solid ${DS_BORDER.default}`, mehr abgefragt.
borderRadius: 2,
bgcolor: DS_BG.subtle,
p: 1.5,
}}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{intro}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Diese Übersicht zeigt den aktuellen Stand. Eingerichtet werden Systemzugänge auf der Seite «Kanäle &
Systeme».
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<Pencil size={14} aria-hidden />}
onClick={() => setBearbeitungsmodus((v) => !v)}
sx={{ textTransform: 'none', ...focusRing }}
>
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Systeme bearbeiten'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Plus size={14} aria-hidden />}
onClick={() => setBearbeitet('new')}
sx={{ textTransform: 'none', ...focusRing }}
>
Neues System anbinden
</Button>
</Box>
</Box> </Box>
{agent.systems.length === 0 ? ( {systems.length === 0 ? (
<Box <Box
sx={{ sx={{
border: `1px solid ${DS_BORDER.default}`, border: `1px solid ${DS_BORDER.default}`,
@@ -212,11 +340,38 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
</Typography> </Typography>
</Box> </Box>
) : ( ) : (
<> <Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
<SystemGroup title={GROUP_TITLE_READ} systems={reading} /> {systems.map((system) => (
<SystemGroup title={GROUP_TITLE_WRITE} systems={writing} /> <SystemRow
</> key={system.id}
system={system}
busy={isPending}
editing={bearbeitungsmodus}
onToggle={handleToggle}
onEdit={setBearbeitet}
onDelete={handleDelete}
/>
))}
</Box>
)} )}
<AgentEntityDialog
open={bearbeitet !== null}
entityKey={bearbeitet ?? undefined}
title={inBearbeitung ? 'System bearbeiten' : 'Neues System anbinden'}
fields={SYSTEM_FIELDS}
initial={{
type: inBearbeitung?.type ?? AgentSystemType.DMS,
access: inBearbeitung?.access ?? AgentAccessLevel.READ,
status: inBearbeitung?.status ?? AgentConnectionStatus.DISCONNECTED,
usage: inBearbeitung?.usage ?? '',
permissionNote: inBearbeitung?.permissionNote ?? '',
}}
activeLabel="Zugang aktiv"
initialActive={inBearbeitung ? istAktiv(inBearbeitung) : true}
onSave={handleDialogSave}
onClose={() => setBearbeitet(null)}
/>
</Box> </Box>
) )
} }
+128 -7
View File
@@ -9,11 +9,14 @@
*/ */
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { Box, Button, Switch, Typography } from '@mui/material' import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material'
import { RotateCcw, Save, ShieldCheck } from 'lucide-react' import { Pencil, Plus, RotateCcw, Save, ShieldCheck, Trash2 } from 'lucide-react'
import type { AgentTask, TeamAgent } from '../../domain/teamAgent' import type { AgentTask, TeamAgent } from '../../domain/teamAgent'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { GenericBadge } from '../shared/GenericBadge' 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' import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
// ── Eine Aufgabenzeile ──────────────────────────────────────────────────────── // ── Eine Aufgabenzeile ────────────────────────────────────────────────────────
@@ -22,13 +25,17 @@ interface TaskRowProps {
task: AgentTask task: AgentTask
busy: boolean busy: boolean
onToggle: (id: string, enabled: boolean) => void 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 * `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). * 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( const handleChange = useCallback(
(_event: unknown, checked: boolean) => onToggle(task.id, checked), (_event: unknown, checked: boolean) => onToggle(task.id, checked),
[onToggle, task.id], [onToggle, task.id],
@@ -71,9 +78,26 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
</Box> </Box>
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
{task.title} <Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary, flex: 1 }}>
</Typography> {task.title}
</Typography>
{editing && (
<Box sx={{ display: 'flex', gap: 0.25, flexShrink: 0 }}>
<Tooltip title="Aufgabe bearbeiten">
<IconButton size="small" onClick={() => onEdit(task.id)} sx={{ color: DS_TEXT.secondary }}>
<Pencil size={14} />
</IconButton>
</Tooltip>
<Tooltip title="Aufgabe löschen">
<IconButton size="small" onClick={() => onDelete(task.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
{/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle {/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle
Wortlaut steht am Element. */} Wortlaut steht am Element. */}
@@ -111,10 +135,27 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
// ── Register «Aufgaben» ─────────────────────────────────────────────────────── // ── 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 }) { export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
const [draft, setDraft] = useState<AgentTask[]>(agent.tasks) const [draft, setDraft] = useState<AgentTask[]>(agent.tasks)
const saveTasks = useSaveAgentTasks() const saveTasks = useSaveAgentTasks()
/** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Eintrags. */
const [bearbeitet, setBearbeitet] = useState<string | null>(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 * Beim Wechsel des Mitarbeiters wird der Entwurf neu aufgesetzt — sonst stünde
* der Entwurf des zuvor gewählten Mitarbeiters in einem fremden Personalblatt. * 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.mutate({ id: agent.id, tasks: draft })
}, [saveTasks, agent.id, 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 busy = saveTasks.isPending
const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } } 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. Speichern für {agent.name} wirksam.
</Typography> </Typography>
{/* Auf Höhe der Überschrift, im Stil des bestehenden «Konfiguration öffnen» (§2A). */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<Pencil size={14} aria-hidden />}
onClick={() => setBearbeitungsmodus(v => !v)}
sx={{ textTransform: 'none', ...focusRing }}
>
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Aufgaben bearbeiten'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Plus size={14} aria-hidden />}
onClick={() => setBearbeitet('new')}
sx={{ textTransform: 'none', ...focusRing }}
>
Neue Aufgabe
</Button>
</Box>
{dirty && ( {dirty && (
<Box <Box
role="status" role="status"
@@ -227,10 +324,34 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
}} }}
> >
{draft.map((task) => ( {draft.map((task) => (
<TaskRow key={task.id} task={task} busy={busy} onToggle={handleToggle} /> <TaskRow
key={task.id}
task={task}
busy={busy}
onToggle={handleToggle}
editing={bearbeitungsmodus}
onEdit={setBearbeitet}
onDelete={handleDelete}
/>
))} ))}
</Box> </Box>
)} )}
<AgentEntityDialog
open={bearbeitet !== null}
title={inBearbeitung ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}
fields={TASK_FIELDS}
entityKey={bearbeitet ?? undefined}
initial={{
title: inBearbeitung?.title ?? '',
description: inBearbeitung?.description ?? '',
schedule: inBearbeitung?.schedule ?? '',
}}
activeLabel="Aufgabe aktiv"
initialActive={inBearbeitung?.enabled ?? true}
onSave={handleDialogSave}
onClose={() => setBearbeitet(null)}
/>
</Box> </Box>
) )
} }
@@ -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)
})
})
+47
View File
@@ -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<T extends MitId>(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<T extends MitId>(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}`
}
+7
View File
@@ -115,6 +115,11 @@ export interface AgentSystem {
type: AgentSystemType type: AgentSystemType
access: AgentAccessLevel access: AgentAccessLevel
status: AgentConnectionStatus 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. */ /** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */
usage: string usage: string
/** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */ /** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */
@@ -242,5 +247,7 @@ export interface UpdateTeamAgentInput {
status?: AgentStatus status?: AgentStatus
tasks?: AgentTask[] tasks?: AgentTask[]
channels?: AgentChannel[] channels?: AgentChannel[]
/** Systeme sind seit Runde 10 (§2) genauso bearbeitbar wie Aufgaben und Kanäle. */
systems?: AgentSystem[]
settings?: AgentSetting[] settings?: AgentSetting[]
} }
+22 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { teamAgentService } from '../services/teamAgentService' 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 { useToastStore } from '../stores/toastStore'
import { STALE_TEAM_AGENTS } from '../lib/constants' import { STALE_TEAM_AGENTS } from '../lib/constants'
import { QK_AGENT_PROTOCOL } from './useAgentProtocol' 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')
},
})
}
+18 -1
View File
@@ -48,6 +48,23 @@ export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
export const AGENT_SUPERVISOR: AgentWorkspace = { export const AGENT_SUPERVISOR: AgentWorkspace = {
id: 'thomas', id: 'thomas',
name: 'Thomas', name: 'Thomas',
role: 'Personalverwalter', role: 'Agentenverwalter',
path: ROUTES.SUPPLY.TEAM, 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,
]
+1
View File
@@ -44,6 +44,7 @@ export const MockupTeamAgentProvider: ITeamAgentProvider = {
...(data.status !== undefined ? { status: data.status } : {}), ...(data.status !== undefined ? { status: data.status } : {}),
...(data.tasks ? { tasks: data.tasks } : {}), ...(data.tasks ? { tasks: data.tasks } : {}),
...(data.channels ? { channels: data.channels } : {}), ...(data.channels ? { channels: data.channels } : {}),
...(data.systems ? { systems: data.systems } : {}),
...(data.settings ? { settings: data.settings } : {}), ...(data.settings ? { settings: data.settings } : {}),
} }
save() save()
+10 -1
View File
@@ -1,5 +1,5 @@
import { MockupTeamAgentProvider } from '../provider/MockupTeamAgentProvider' 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 { AgentStatus } from '../domain/teamAgent'
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol' import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
import type { ListResponse, ItemResponse } from './types' import type { ListResponse, ItemResponse } from './types'
@@ -95,4 +95,13 @@ export const teamAgentService = {
throwServiceError(err) throwServiceError(err)
} }
}, },
/** Systeme speichern — Runde 10, §2: gleiche Mechanik wie Aufgaben und Kanäle. */
async saveSystems(id: string, systems: AgentSystem[]): Promise<ItemResponse<TeamAgent>> {
try {
const data = await provider.update(id, { systems })
return { data }
} catch (err) {
throwServiceError(err)
}
},
} }