78e2dab655
Vier Seiten, eingebettet in die bestehende App-Shell — keine zweite Sidebar, keine zweite Kopfzeile, kein eigenes Property-On-Branding: - Teamübersicht: Auswertung (drei Kennzahlen, gemeinsam filterbar nach Zeitraum, Bereich und Mitarbeiter), Kernteam, Kanäle & Systeme — in dieser Reihenfolge - Bearbeitungsverlauf: zwei Reiter, neun Filterdimensionen, Detail-Drawer mit Ergebnis, Eingabedaten, Verarbeitungsschritten, Fundstellen, Nachrichtenverlauf und Aktionshistorie; Freigeben, Anpassen, Zurückweisen, Rückfrage beantworten - Personalverwaltung: dreigeteilt (Hauptnavigation, Agentenliste, Dossier) mit fünf Reitern — Aufgaben, Kanäle, Systeme, Einstellungen, Protokoll - Kanäle & Systeme: neun Verbindungen, mehrstufiger Assistent, ERP-Auswahl, deterministischer Verbindungstest Navigation: NavItem um `children` erweitert, "Teamübersicht" steht exakt zwischen "Meine Objekte" und "Reminder Manager", die drei Subreiter bleiben eingerückt sichtbar, solange man im Funktionsbereich ist. Subreiter sind echte Routen, keine lokalen Tabs. Die App kannte bisher keine Feature-Route mit eigenem Outlet und baut Tabs sonst über lokalen useState — hier trug das nicht: die Subreiter müssen in der Sidebar stehen und deep-linkbar sein. Die Routen sind flache Geschwister, kein Outlet-Baum. Freigabepflichtige Aktionen laufen über einen Bestätigungsdialog mit sichtbarer Konsequenz; die 1-Klick-Bestätigung ist die fachlich vorgesehene Ausnahme. Jeder Status trägt neben der Farbe immer einen Text. Null rohe Hex-Werte: check:tokens bleibt unverändert bei 2249. Der in der Spezifikation genannte dunkelblaue Banner "Heute geleistet …" existiert in dieser Codebasis nicht — er wird deshalb auch nicht erzeugt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
8.8 KiB
TypeScript
236 lines
8.8 KiB
TypeScript
/**
|
|
* Property On — Register «Aufgaben» des Personalblatts (§8).
|
|
*
|
|
* Die Liste zeigt jede Aufgabe des gewählten Mitarbeiters einzeln schaltbar.
|
|
* Geändert wird zuerst nur ein lokaler Entwurf: Zuschalten und Pausieren
|
|
* verändert, was ein digitaler Mitarbeiter im Alltag der Bewirtschaftung tut —
|
|
* das darf nicht beiläufig im Hintergrund passieren, sondern erst mit einer
|
|
* bewussten Freigabe über «Änderungen speichern» (§8.2).
|
|
*/
|
|
|
|
import { memo, useCallback, useMemo, useState } from 'react'
|
|
import { Box, Button, Switch, Typography } from '@mui/material'
|
|
import { Clock, Plug, RotateCcw, Save, ShieldCheck } from 'lucide-react'
|
|
import type { AgentTask, TeamAgent } from '../../domain/teamAgent'
|
|
import { AGENT_CHANNEL_LABELS, AGENT_SYSTEM_LABELS } from '../../lib/constants'
|
|
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
|
import { GenericBadge } from '../shared/GenericBadge'
|
|
import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
|
|
|
|
// ── Eine Aufgabenzeile ────────────────────────────────────────────────────────
|
|
|
|
interface TaskRowProps {
|
|
task: AgentTask
|
|
busy: boolean
|
|
onToggle: (id: string, enabled: boolean) => void
|
|
}
|
|
|
|
/**
|
|
* `React.memo` ist Pflicht: ein Mitarbeiter führt bis zu zwölf Aufgaben, und
|
|
* jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2).
|
|
*/
|
|
const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
|
const dependencies = useMemo(() => {
|
|
const out: string[] = []
|
|
if (task.dependsOnChannel) {
|
|
out.push(`Kanal: ${AGENT_CHANNEL_LABELS[task.dependsOnChannel] ?? task.dependsOnChannel}`)
|
|
}
|
|
if (task.dependsOnSystem) {
|
|
out.push(`Systemzugang: ${AGENT_SYSTEM_LABELS[task.dependsOnSystem] ?? task.dependsOnSystem}`)
|
|
}
|
|
return out
|
|
}, [task.dependsOnChannel, task.dependsOnSystem])
|
|
|
|
const handleChange = useCallback(
|
|
(_event: unknown, checked: boolean) => onToggle(task.id, checked),
|
|
[onToggle, task.id],
|
|
)
|
|
|
|
return (
|
|
<Box
|
|
component="li"
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 1.5,
|
|
alignItems: 'flex-start',
|
|
p: 1.75,
|
|
border: `1px solid ${DS_BORDER.default}`,
|
|
borderRadius: 2,
|
|
bgcolor: task.enabled ? DS_BG.surface : DS_BG.subtle,
|
|
boxShadow: task.enabled ? DS_SHADOW.card : 'none',
|
|
}}
|
|
>
|
|
{/* Schalter mit Zustand als Text — Farbe allein trägt keinen Status (§18) */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, width: 76 }}>
|
|
<Switch
|
|
checked={task.enabled}
|
|
disabled={busy}
|
|
onChange={handleChange}
|
|
slotProps={{ input: { 'aria-label': `Aufgabe «${task.title}» aktivieren` } }}
|
|
sx={{
|
|
'& .MuiSwitch-switchBase.Mui-focusVisible + .MuiSwitch-track': {
|
|
outline: `2px solid ${DS_TEXT.brand}`,
|
|
outlineOffset: 2,
|
|
},
|
|
}}
|
|
/>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ fontWeight: 600, color: task.enabled ? DS_TEXT.success : DS_TEXT.muted }}
|
|
>
|
|
{task.enabled ? 'Aktiv' : 'Pausiert'}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
|
{task.title}
|
|
</Typography>
|
|
|
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
|
|
{task.description}
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}>
|
|
<Clock size={13} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
|
Zeitplan: {task.schedule}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{dependencies.map((dependency) => (
|
|
<Box key={dependency} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.375 }}>
|
|
<Plug size={13} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
|
Setzt voraus — {dependency}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
|
|
{task.requiresApproval && (
|
|
<Box sx={{ mt: 1 }}>
|
|
<GenericBadge
|
|
label="Freigabe erforderlich"
|
|
semanticVariant="confidenceMedium"
|
|
bold
|
|
icon={<ShieldCheck size={13} aria-hidden />}
|
|
tooltip="Das Ergebnis dieser Aufgabe wird erst nach Ihrer Freigabe wirksam."
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
})
|
|
|
|
// ── Register «Aufgaben» ───────────────────────────────────────────────────────
|
|
|
|
export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
|
|
const [draft, setDraft] = useState<AgentTask[]>(agent.tasks)
|
|
const saveTasks = useSaveAgentTasks()
|
|
|
|
/**
|
|
* Beim Wechsel des Mitarbeiters wird der Entwurf neu aufgesetzt — sonst stünde
|
|
* der Entwurf des zuvor gewählten Mitarbeiters in einem fremden Personalblatt.
|
|
* Bewusst nur an `agent.id` gebunden: liefe der Effekt auch bei jeder neuen
|
|
* Referenz von `agent.tasks`, würde ein Hintergrundabgleich unbestätigte
|
|
* Änderungen still verwerfen.
|
|
*/
|
|
// Kein Effekt nötig: `Personalverwaltung` gibt diesem Reiter die Mitarbeiter-ID
|
|
// als `key` mit, er wird beim Wechsel des Dossiers ohnehin neu aufgebaut.
|
|
|
|
/** Ein Durchlauf für beide Ableitungen — Anzahl aktiver Aufgaben und Abweichung (§10.4). */
|
|
const { activeCount, dirty } = useMemo(() => {
|
|
const saved = new Map(agent.tasks.map((task) => [task.id, task.enabled]))
|
|
let active = 0
|
|
let changed = draft.length !== agent.tasks.length
|
|
for (const task of draft) {
|
|
if (task.enabled) active += 1
|
|
if (saved.get(task.id) !== task.enabled) changed = true
|
|
}
|
|
return { activeCount: active, dirty: changed }
|
|
}, [draft, agent.tasks])
|
|
|
|
const handleToggle = useCallback((id: string, enabled: boolean) => {
|
|
setDraft((prev) => prev.map((task) => (task.id === id ? { ...task, enabled } : task)))
|
|
}, [])
|
|
|
|
const handleReset = useCallback(() => setDraft(agent.tasks), [agent.tasks])
|
|
|
|
const handleSave = useCallback(() => {
|
|
saveTasks.mutate({ id: agent.id, tasks: draft })
|
|
}, [saveTasks, agent.id, draft])
|
|
|
|
const busy = saveTasks.isPending
|
|
const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
|
|
|
|
return (
|
|
<Box>
|
|
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
|
{activeCount} von {draft.length} Aufgaben aktiv
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}>
|
|
Jede Aufgabe lässt sich einzeln zuschalten oder pausieren. Änderungen werden erst mit dem
|
|
Speichern für {agent.name} wirksam.
|
|
</Typography>
|
|
|
|
{dirty && (
|
|
<Box
|
|
role="status"
|
|
aria-live="polite"
|
|
sx={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
flexWrap: 'wrap',
|
|
mt: 1.75,
|
|
p: 1.5,
|
|
borderRadius: 2,
|
|
bgcolor: DS_SURFACE.warning.bg,
|
|
border: `1px solid ${DS_SURFACE.warning.border}`,
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.warningDark }}>
|
|
Nicht gespeicherte Änderungen
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
disabled={busy}
|
|
startIcon={<Save size={15} />}
|
|
onClick={handleSave}
|
|
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto', ...focusRing }}
|
|
>
|
|
Änderungen speichern
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
disabled={busy}
|
|
startIcon={<RotateCcw size={15} />}
|
|
onClick={handleReset}
|
|
sx={{ textTransform: 'none', fontWeight: 600, ...focusRing }}
|
|
>
|
|
Zurücksetzen
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
{draft.length === 0 ? (
|
|
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 2 }}>
|
|
Für {agent.name} sind noch keine Aufgaben hinterlegt.
|
|
</Typography>
|
|
) : (
|
|
<Box component="ul" sx={{ listStyle: 'none', m: 0, mt: 1.75, p: 0, display: 'grid', gap: 1.25 }}>
|
|
{draft.map((task) => (
|
|
<TaskRow key={task.id} task={task} busy={busy} onToggle={handleToggle} />
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|