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:
1 parent
c6f3b2d6e3
commit
1c4b3f7dd8
15 files changed
+931
-110
No files matched your search
@@ -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<string, string> = {
|
||||
* 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() {
|
||||
<MenuSection>
|
||||
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
|
||||
{/* 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)}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
|
||||
{agent.name}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<WorkspaceType, WorkspaceConfig> = {
|
||||
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,
|
||||
|
||||
@@ -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, 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.' },
|
||||
}
|
||||
|
||||
// ── 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 }:
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction}
|
||||
</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>
|
||||
|
||||
@@ -197,6 +268,10 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
|
||||
const { mutate: saveChannels } = useSaveAgentChannels()
|
||||
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
|
||||
|
||||
// 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 (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus.
|
||||
</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
|
||||
</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 ? (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>
|
||||
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 }}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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<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
|
||||
|
||||
const letzteFrageIndex = messages.map(m => m.from).lastIndexOf('user')
|
||||
|
||||
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) => (
|
||||
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
|
||||
sx={{
|
||||
px: 1.75, py: 1,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<AgentAccessLevel, AccessAppearance> = {
|
||||
},
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
@@ -81,21 +150,42 @@ const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) {
|
||||
sx={{
|
||||
border: `1px solid ${look.border}`,
|
||||
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,
|
||||
display: 'grid',
|
||||
gap: 0.875,
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile: System, Zugriffstyp, Verbindungsstatus */}
|
||||
{/* Kopfzeile: System, Aktivzustand, Zugriffstyp, Verbindungsstatus */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} />
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
||||
{AGENT_SYSTEM_LABELS[system.type] ?? system.type}
|
||||
{label}
|
||||
</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} />
|
||||
<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>
|
||||
|
||||
@@ -109,6 +199,7 @@ const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) {
|
||||
</Typography>
|
||||
|
||||
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */}
|
||||
{system.permissionNote && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -128,75 +219,112 @@ const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) {
|
||||
{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>
|
||||
</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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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)}.`
|
||||
/** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Zugangs. */
|
||||
const [bearbeitet, setBearbeitet] = useState<string | null>(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,
|
||||
}
|
||||
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])
|
||||
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 (
|
||||
<Box sx={{ display: 'grid', gap: 2.5 }}>
|
||||
{/* Einordnung — dieser Reiter zeigt den Stand, er verändert ihn nicht */}
|
||||
<Box
|
||||
sx={{
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
borderRadius: 2,
|
||||
bgcolor: DS_BG.subtle,
|
||||
p: 1.5,
|
||||
}}
|
||||
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Diese Systeme und Datenquellen darf {agent.name} verwenden. Ein abgeschalteter Zugang wird nicht
|
||||
mehr abgefragt.
|
||||
</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 }}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{agent.systems.length === 0 ? (
|
||||
{systems.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
@@ -212,11 +340,38 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<SystemGroup title={GROUP_TITLE_READ} systems={reading} />
|
||||
<SystemGroup title={GROUP_TITLE_WRITE} systems={writing} />
|
||||
</>
|
||||
<Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
|
||||
{systems.map((system) => (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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,10 +78,27 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
||||
</Box>
|
||||
|
||||
<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 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary, flex: 1 }}>
|
||||
{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
|
||||
Wortlaut steht am Element. */}
|
||||
<Typography
|
||||
@@ -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<AgentTask[]>(agent.tasks)
|
||||
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
|
||||
* 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.
|
||||
</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 && (
|
||||
<Box
|
||||
role="status"
|
||||
@@ -227,10 +324,34 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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<ItemResponse<TeamAgent>> {
|
||||
try {
|
||||
const data = await provider.update(id, { systems })
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user