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
+952
-131
No files matched your search
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
* 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
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[]
|
||||||
}
|
}
|
||||||
@@ -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')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in new issue
Block a user