feat(team): Oberfläche der Teamübersicht
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>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { Box, Divider, Drawer, IconButton, Typography } from '@mui/material'
|
||||
import { HelpCircle, X } from 'lucide-react'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
|
||||
import { WorkItemActions } from './WorkItemActions'
|
||||
import {
|
||||
DetailFieldList,
|
||||
DetailSectionTitle,
|
||||
MessageThread,
|
||||
ProcessingStepList,
|
||||
SourceReferenceList,
|
||||
} from './WorkItemDetailSections'
|
||||
import { PanelLoadingState } from '../ui'
|
||||
import { AGENT_WORK_ITEM_KIND_LABELS } from '../../lib/constants'
|
||||
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { formatTeamDateTime } from '../../lib/teamClock'
|
||||
|
||||
/**
|
||||
* Detailansicht eines Vorgangs (§5.8).
|
||||
*
|
||||
* Als Drawer statt eigener Route: der Nutzer arbeitet eine Liste ab und will
|
||||
* nach jeder Entscheidung sofort wieder in ihr stehen — ein Seitenwechsel
|
||||
* würde bei jedem Vorgang den Kontext zerstören.
|
||||
*/
|
||||
export function WorkItemDetailDrawer() {
|
||||
const selectedId = useTeamStore(s => s.selectedWorkItemId)
|
||||
const setSelectedId = useTeamStore(s => s.setSelectedWorkItemId)
|
||||
const { data: item, isLoading } = useAgentWorkItem(selectedId)
|
||||
const { data: agents = [] } = useTeamAgents()
|
||||
const sourcesRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const agent = agents.find(a => a.id === item?.agentId)
|
||||
const close = useCallback(() => setSelectedId(null), [setSelectedId])
|
||||
|
||||
const scrollToSources = useCallback(() => {
|
||||
sourcesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!selectedId}
|
||||
onClose={close}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560, lg: 640 }, display: 'flex', flexDirection: 'column' } } }}
|
||||
>
|
||||
{/* Kopfbereich */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
p: 2,
|
||||
borderBottom: `1px solid ${DS_BORDER.default}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{agent && <AgentAvatar agent={agent} size="medium" />}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
|
||||
</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, lineHeight: 1.35 }}>
|
||||
{item?.title ?? 'Vorgang wird geladen'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={close} aria-label="Detailansicht schliessen">
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Inhalt */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
|
||||
{isLoading && <PanelLoadingState />}
|
||||
|
||||
{item && (
|
||||
<Box sx={{ p: 2, display: 'grid', gap: 2.5 }}>
|
||||
{/* Metazeile */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<AgentPriorityBadge priority={item.priority} />
|
||||
<AgentWorkItemStatusBadge status={item.status} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind}
|
||||
</Typography>
|
||||
{item.objectId && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
· {item.objectId}{item.objectLabel ? ` · ${item.objectLabel}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto' }}>
|
||||
{formatTeamDateTime(item.completedAt ?? item.createdAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.summary}</Typography>
|
||||
|
||||
{/* Rückfrage — bei pendenten Vorgängen die wichtigste Information */}
|
||||
{item.requiresDecision && (item.escalationReason || item.decisionQuestion) && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: DS_SURFACE.warning.bg,
|
||||
border: `1px solid ${DS_SURFACE.warning.border}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||
<HelpCircle size={15} color={DS_TEXT.warning} aria-hidden />
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', color: DS_TEXT.warningDark }}>
|
||||
Warum ist ein Mensch erforderlich?
|
||||
</Typography>
|
||||
</Box>
|
||||
{item.escalationReason && (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark }}>
|
||||
{item.escalationReason}
|
||||
</Typography>
|
||||
)}
|
||||
{item.decisionQuestion && (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600, mt: 1 }}>
|
||||
{item.decisionQuestion}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.result.length > 0 && (
|
||||
<Box>
|
||||
<DetailSectionTitle>{item.requiresDecision ? 'Aktueller Stand' : 'Ergebnis'}</DetailSectionTitle>
|
||||
<DetailFieldList fields={item.result} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.messageThread && item.messageThread.length > 0 && (
|
||||
<Box>
|
||||
<DetailSectionTitle>Nachrichtenverlauf</DetailSectionTitle>
|
||||
<MessageThread messages={item.messageThread} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.inputs.length > 0 && (
|
||||
<Box>
|
||||
<DetailSectionTitle>Eingabedaten</DetailSectionTitle>
|
||||
<DetailFieldList fields={item.inputs} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.processingSteps.length > 0 && (
|
||||
<Box>
|
||||
<DetailSectionTitle>Verarbeitungsschritte</DetailSectionTitle>
|
||||
<ProcessingStepList steps={item.processingSteps} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.sourceReferences.length > 0 && (
|
||||
<Box ref={sourcesRef} sx={{ scrollMarginTop: 8 }}>
|
||||
<DetailSectionTitle>Fundstellen</DetailSectionTitle>
|
||||
<SourceReferenceList references={item.sourceReferences} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.rejectionReason && (
|
||||
<Box>
|
||||
<DetailSectionTitle>Begründung der Zurückweisung</DetailSectionTitle>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{item.history.length > 0 && (
|
||||
<Box>
|
||||
<DetailSectionTitle>Aktionshistorie</DetailSectionTitle>
|
||||
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
||||
{item.history.map((entry) => (
|
||||
<Box key={entry.id} sx={{ display: 'flex', gap: 1, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, minWidth: 128 }}>
|
||||
{formatTeamDateTime(entry.at)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
|
||||
{entry.action}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
{entry.actor}
|
||||
</Typography>
|
||||
{entry.note && (
|
||||
<>
|
||||
<Divider flexItem orientation="vertical" />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{entry.note}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{item && item.availableActions.length > 0 && (
|
||||
<WorkItemActions item={item} onOpenSource={scrollToSources} />
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user