refactor(team): zweite Überarbeitungsrunde — Oberfläche bereinigen

Gezielte UI-Bereinigung ohne neue Architektur. Bestehende Komponenten angepasst,
Datenstrukturen und Routing unverändert.

GLOBAL
Chip «Verwaltung» und Seitenname in der Kopfzeile entfallen auf /supply/team/*;
die Seiten tragen ihren Titel bereits gross im Inhalt. Andere Module behalten die
Zeile. Demo-Modus-Hinweis und alle Untertitel entfernt. Autonomiegrad aus der
Oberfläche genommen — die Daten bleiben unangetastet.

PERSONALVERWALTUNG
Beschrieb auf zwei Sätze gekürzt, Input/Kernablauf/Output entfallen: das war
Prozessnotation, sie beschrieb wie gearbeitet wird, nicht wofür man jemanden hat.
Zuständigkeiten stehen jetzt im Kopfbereich statt tief im Dossier. Reiterfolge
neu: Agentenbeschrieb, Aufgaben, Kennzahlen, Kanäle & Systeme, Protokoll.
Kanäle und Systeme sind über eine dünne Hülle zusammengeführt — die bestehende
Logik samt Konfigurationsdialog bleibt unberührt. Aufgaben dreispaltig ohne
Zeitplan, Ereignisfilter im Protokoll entfernt.

Der abgelöste Reiter «Systeme» wird weiterhin aufgelöst, damit bestehende
Verweise nicht still auf den ersten Reiter zurückfallen.

ORGANIGRAMM — die eigentliche Korrektur
Der falsch gewählte Agent lag an der Geometrie: `rotate(a) translateY(r)` schiebt
bei a = 0 nach UNTEN, die Drehung wurde aber als `180 − index·step` berechnet.
Damit stand der gewählte Mitarbeitende oben und das Dossier darunter gehörte zum
falschen. Jetzt `−index·step`.

Dazu beruhigt: Zugempfindlichkeit von 0.5 auf 0.22 Grad je Pixel, 10 px Totzone
gegen Zittern, kurzer Zug wechselt höchstens einen Platz, Einrasten in 250 ms,
Auswahl erst nach dem Einrasten. Kreis von 720 auf 460 px.

BEARBEITUNGSVERLAUF
Filter ohne Vorgangstyp, Priorität und Kanal; Suche in Zeile eins, Rest in Zeile
zwei. Karten tragen nur noch Agent, Titel mit Liegenschaft, zwei Zeilen Text und
Zeitstempel — Priorität, Status, Vorgangstyp und Kanal standen auf praktisch
jeder Karte und trugen damit keine Information mehr.

Drawer ohne Verarbeitungsschritte und Aktionshistorie; nur die auslösende
Nachricht plus Quicklink ins Anfragencenter statt des ganzen Verlaufs.
«Fundstellen» heisst «Quellen». Der Entscheidungshinweis heisst jetzt
«Hierzu brauche ich Ihre Entscheidung». Aktionsleiste: pendent Freigeben,
Zurückweisen, Quellen anzeigen, Inserat anzeigen — erledigt nur «Objekt anzeigen».

KANÄLE & SYSTEME, TEAMÜBERSICHT
Kanäle vor Systemen als eigene Abschnitte, vier Karten pro Zeile, Beschreibung
und Statusbadge raus, Regler mit Klartext Aktiv/Inaktiv/Geplant. Die Kreisgrafik
skaliert nach Anzahl sichtbarer Ringe und ist auf die Viewport-Höhe begrenzt,
damit das Kernteam ohne Scrollen vollständig sichtbar bleibt.

Drei Testzusicherungen prüfen jetzt die Abwesenheit statt der Anwesenheit von
Objekt-ID und Rückfragegrund auf der Karte — beides wurde bewusst entfernt.

Typecheck grün, ESLint über die geänderten Dateien grün, Build grün, 413 Tests grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-08-04 13:30:32 +02:00
parent 8377ce03b5
commit 177641386a
22 changed files with 667 additions and 784 deletions
+9
View File
@@ -27,6 +27,11 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
const pageName = getPageNameFromPath(pathname) const pageName = getPageNameFromPath(pathname)
const openAssistant = useAssistantStore(s => s.open) const openAssistant = useAssistantStore(s => s.open)
// Die Property-On-Seiten tragen ihren Titel bereits gross im Inhalt. Das Chip
// «Verwaltung» plus derselbe Seitenname darüber wäre eine doppelte Angabe und
// kostet nur vertikale Höhe. Andere Module behalten die Zeile unverändert.
const showBreadcrumb = !pathname.startsWith('/supply/team')
return ( return (
<Box <Box
component="header" component="header"
@@ -48,6 +53,8 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Menu size={20} /> <Menu size={20} />
</IconButton> </IconButton>
)} )}
{showBreadcrumb && (
<>
<Chip <Chip
label={config.label} label={config.label}
size="small" size="small"
@@ -66,6 +73,8 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
> >
{pageName} {pageName}
</Typography> </Typography>
</>
)}
</Box> </Box>
{/* Right side */} {/* Right side */}
@@ -0,0 +1,54 @@
import { Box } from '@mui/material'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentChannelsTab } from './AgentChannelsTab'
import { AgentSystemsTab } from './AgentSystemsTab'
import { DS_TEXT } from '../../lib/ds'
import { Typography } from '@mui/material'
/**
* Zusammengeführter Reiter «Kanäle & Systeme».
*
* Bewusst eine dünne Hülle über die beiden bestehenden Reiter statt einer
* Neufassung: die Kanal- und Systemlogik samt Konfigurationsdialog bleibt
* unangetastet, es ändert sich nur, dass beides auf einer Fläche steht.
* Reihenfolge wie vorgegeben — zuerst Kanäle, darunter Systeme.
*/
export function AgentConnectionsTab({ agent }: { agent: TeamAgent }) {
return (
<Box sx={{ display: 'grid', gap: 4 }}>
<Box component="section" aria-label="Kanäle">
<Typography
component="h3"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
mb: 1.5,
}}
>
Kanäle
</Typography>
<AgentChannelsTab agent={agent} />
</Box>
<Box component="section" aria-label="Systeme">
<Typography
component="h3"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
mb: 1.5,
}}
>
Systeme
</Typography>
<AgentSystemsTab agent={agent} />
</Box>
</Box>
)
}
+31 -123
View File
@@ -1,151 +1,59 @@
/** /**
* Property On — Reiter «Agentenbeschrieb» im Personaldossier. * Property On — Reiter «Agentenbeschrieb» im Personaldossier.
* *
* Der Einstieg ins Dossier: Er beantwortet in wenigen Zeilen, wofür ein * Bewusst nur ein kurzer Absatz. Die frühere Dreiteilung aus Input, Kernablauf
* digitaler Mitarbeiter zuständig ist, womit er arbeitet, was dabei herauskommt * und Output war eine Prozessnotation — sie beschrieb, wie der Mitarbeitende
* und wie viel davon zuletzt angefallen ist. Sämtliche Angaben stammen * arbeitet, nicht wofür man ihn hat. Die Zuständigkeiten stehen jetzt im
* unverändert aus dem Personaldossier — hier wird nichts formuliert, was nicht * Kopfbereich, die Kennzahlen im eigenen Reiter.
* in den Daten steht.
* *
* Zwei bewusste Entscheide: * Es wird nichts formuliert, was nicht in den Daten steht: beide Sätze stammen
* * unverändert aus dem Personalblatt. Sagen sie dasselbe, bleibt einer stehen.
* 1. Der Zweck steht genau einmal auf der Seite. Die geteilte Informationsbox
* zeigt ihn bereits über den drei Spalten; der Kurzbeschrieb darüber
* entfällt deshalb, sobald er inhaltlich dasselbe sagt.
* 2. Die Zuständigkeiten stehen gekürzt. Je nach Mitarbeiter sind bis zu neun
* Punkte hinterlegt — als vollständige Liste verdrängen sie die eigentliche
* Aussage nach unten. Sichtbar sind die ersten drei, der Rest wird beziffert.
* Die Daten selbst bleiben unangetastet.
*/ */
import { memo, useMemo } from 'react' import { useMemo } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import type { AgentMetric, TeamAgent } from '../../domain/teamAgent' import type { TeamAgent } from '../../domain/teamAgent'
import { AgentInfoBox } from './AgentInfoBox' import { DS_TEXT } from '../../lib/ds'
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
/** Sichtbare Zuständigkeiten; alles Weitere wird nur noch gezählt. */
const VISIBLE_RESPONSIBILITIES = 3
/** /**
* Doppelung erkennen, ohne Text zu erfinden: Kurze Wörter tragen im Deutschen * Doppelung erkennen, ohne Text zu erfinden: Kurze Wörter tragen im Deutschen
* kaum Bedeutung («und», «bis», «der»), deshalb zählen nur längere. Deckt der * kaum Bedeutung («und», «bis», «der»), deshalb zählen nur längere.
* Zweck den Kurzbeschrieb weitgehend ab, ist der Kurzbeschrieb eine Wiederholung.
*/ */
const MIN_WORD_LENGTH = 5 const MIN_WORD_LENGTH = 5
const REDUNDANCY_THRESHOLD = 0.6 const REDUNDANCY_THRESHOLD = 0.6
function contentWords(text: string): Set<string> { function contentWords(text: string): Set<string> {
const words = text return new Set(
.toLowerCase() text.toLowerCase().split(/[^a-zäöüß]+/).filter(w => w.length >= MIN_WORD_LENGTH),
.split(/[^a-zäöüéèàç]+/) )
.filter(word => word.length >= MIN_WORD_LENGTH)
return new Set(words)
} }
function saysTheSame(shortText: string, longText: string): boolean { function saysTheSame(a: string, b: string): boolean {
const own = contentWords(shortText) const left = contentWords(a)
if (own.size === 0) return true if (left.size === 0) return false
const other = contentWords(longText) const right = contentWords(b)
let shared = 0 let shared = 0
for (const word of own) if (other.has(word)) shared += 1 for (const word of left) if (right.has(word)) shared += 1
return shared / own.size >= REDUNDANCY_THRESHOLD return shared / left.size >= REDUNDANCY_THRESHOLD
} }
// ── Bausteine ─────────────────────────────────────────────────────────────────
function SectionLabel({ children }: { children: string }) {
return (
<Typography
component="h3"
sx={{
fontWeight: 700,
fontSize: '0.75rem',
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: DS_TEXT.muted,
}}
>
{children}
</Typography>
)
}
const MetricItem = memo(function MetricItem({ metric }: { metric: AgentMetric }) {
return (
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.5rem', lineHeight: 1.15, color: DS_TEXT.primary }}>
{metric.value}
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>
{metric.label}
</Typography>
</Box>
)
})
const ResponsibilityItem = memo(function ResponsibilityItem({ text }: { text: string }) {
return (
<Typography component="li" variant="body2" sx={{ color: DS_TEXT.secondary }}>
{text}
</Typography>
)
})
// ── Reiter ────────────────────────────────────────────────────────────────────
export function AgentDescriptionTab({ agent }: { agent: TeamAgent }) { export function AgentDescriptionTab({ agent }: { agent: TeamAgent }) {
const showShortDescription = useMemo( const sentences = useMemo(() => {
() => !saysTheSame(agent.shortDescription, agent.profile.purpose), const first = agent.shortDescription.trim()
[agent.shortDescription, agent.profile.purpose], const second = agent.profile.purpose.trim()
) return saysTheSame(first, second) ? [first] : [first, second]
}, [agent.shortDescription, agent.profile.purpose])
const visibleResponsibilities = useMemo(
() => agent.responsibilities.slice(0, VISIBLE_RESPONSIBILITIES),
[agent.responsibilities],
)
const hiddenResponsibilities = agent.responsibilities.length - visibleResponsibilities.length
return ( return (
<Box> <Box sx={{ maxWidth: '78ch', display: 'grid', gap: 1 }}>
{showShortDescription && ( {sentences.map((sentence) => (
<Typography sx={{ maxWidth: '72ch', lineHeight: 1.6, color: DS_TEXT.primary }}> <Typography
{agent.shortDescription} key={sentence}
sx={{ color: DS_TEXT.primary, fontSize: '0.9375rem', lineHeight: 1.65 }}
>
{sentence}
</Typography> </Typography>
)}
{/* Zweck, Input, Kernablauf und Output — dieselbe Informationsbox wie in
der Teamübersicht, damit derselbe Sachverhalt nicht zweimal gebaut
wird. Sie bringt eigene Aussenabstände mit; der Reiter liegt bereits in
einer Fläche mit demselben Innenabstand, deshalb wird der doppelte
Einzug hier ausgeglichen statt die geteilte Komponente zu ändern. */}
<Box sx={{ mx: -3 }}>
<AgentInfoBox profile={agent.profile} />
</Box>
<Box component="section" sx={{ mt: 3 }}>
<SectionLabel>Kennzahlen</SectionLabel>
<Box sx={{ display: 'flex', flexWrap: 'wrap', columnGap: 4, rowGap: 2, mt: 1.25 }}>
{agent.metrics.map((metric) => (
<MetricItem key={metric.id} metric={metric} />
))} ))}
</Box> </Box>
</Box>
<Box component="section" sx={{ mt: 3, pt: 2.5, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<SectionLabel>Zuständigkeiten</SectionLabel>
<Box component="ul" sx={{ m: 0, mt: 1, pl: 2.25, display: 'grid', gap: 0.5 }}>
{visibleResponsibilities.map((text) => (
<ResponsibilityItem key={text} text={text} />
))}
</Box>
{hiddenResponsibilities > 0 && (
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: DS_TEXT.muted }}>
{hiddenResponsibilities} weitere von insgesamt {agent.responsibilities.length} Zuständigkeiten
</Typography>
)}
</Box>
</Box>
) )
} }
+26 -8
View File
@@ -19,8 +19,8 @@ import type { TeamAgent } from '../../domain/teamAgent'
import { AgentDossierHeader } from './AgentDossierHeader' import { AgentDossierHeader } from './AgentDossierHeader'
import { AgentDescriptionTab } from './AgentDescriptionTab' import { AgentDescriptionTab } from './AgentDescriptionTab'
import { AgentTasksTab } from './AgentTasksTab' import { AgentTasksTab } from './AgentTasksTab'
import { AgentChannelsTab } from './AgentChannelsTab' import { AgentMetricsTab } from './AgentMetricsTab'
import { AgentSystemsTab } from './AgentSystemsTab' import { AgentConnectionsTab } from './AgentConnectionsTab'
import { AgentSettingsTab } from './AgentSettingsTab' import { AgentSettingsTab } from './AgentSettingsTab'
import { AgentProtocolTab } from './AgentProtocolTab' import { AgentProtocolTab } from './AgentProtocolTab'
import { DS_BG, DS_BORDER } from '../../lib/ds' import { DS_BG, DS_BORDER } from '../../lib/ds'
@@ -37,21 +37,39 @@ import { DS_BG, DS_BORDER } from '../../lib/ds'
export const DOSSIER_TABS = [ export const DOSSIER_TABS = [
{ segment: 'beschrieb', label: 'Agentenbeschrieb' }, { segment: 'beschrieb', label: 'Agentenbeschrieb' },
{ segment: 'aufgaben', label: 'Aufgaben' }, { segment: 'aufgaben', label: 'Aufgaben' },
{ segment: 'kanaele', label: 'Kanäle' }, { segment: 'kennzahlen', label: 'Kennzahlen' },
{ segment: 'systeme', label: 'Systeme' }, { segment: 'kanaele', label: 'Kanäle & Systeme' },
{ segment: 'einstellungen', label: 'Einstellungen' },
{ segment: 'protokoll', label: 'Protokoll' }, { segment: 'protokoll', label: 'Protokoll' },
// Einstellungen steht nicht in der neuen Reihenfolge, bleibt aber erhalten:
// Meldeschwellen, Quellenzwang und Freigaberegeln sind fachlich notwendig und
// haben sonst keinen Ort in der Oberfläche.
{ segment: 'einstellungen', label: 'Einstellungen' },
] as const ] as const
export type DossierSegment = typeof DOSSIER_TABS[number]['segment'] export type DossierSegment = typeof DOSSIER_TABS[number]['segment']
export const DEFAULT_DOSSIER_SEGMENT: DossierSegment = 'beschrieb' export const DEFAULT_DOSSIER_SEGMENT: DossierSegment = 'beschrieb'
/**
* Der frühere eigene Reiter «Systeme» ist in «Kanäle & Systeme» aufgegangen.
* Bestehende Verweise auf das alte Segment führen weiterhin ans Ziel, statt
* still auf den ersten Reiter zurückzufallen.
*/
const LEGACY_SEGMENTS: Record<string, DossierSegment> = { systeme: 'kanaele' }
// eslint-disable-next-line react-refresh/only-export-components -- siehe DOSSIER_TABS // eslint-disable-next-line react-refresh/only-export-components -- siehe DOSSIER_TABS
export function isDossierSegment(value: string | undefined): value is DossierSegment { export function isDossierSegment(value: string | undefined): value is DossierSegment {
return DOSSIER_TABS.some(tab => tab.segment === value) return DOSSIER_TABS.some(tab => tab.segment === value)
} }
/** Löst ein Segment aus der Adresse auf, inklusive der abgelösten Schreibweise. */
// eslint-disable-next-line react-refresh/only-export-components -- siehe DOSSIER_TABS
export function resolveDossierSegment(value: string | undefined): DossierSegment {
if (isDossierSegment(value)) return value
if (value && LEGACY_SEGMENTS[value]) return LEGACY_SEGMENTS[value]
return DEFAULT_DOSSIER_SEGMENT
}
/** Verbindet Reiter und Inhaltsfläche für Screenreader. */ /** Verbindet Reiter und Inhaltsfläche für Screenreader. */
const tabId = (segment: DossierSegment) => `agent-dossier-tab-${segment}` const tabId = (segment: DossierSegment) => `agent-dossier-tab-${segment}`
const panelId = (segment: DossierSegment) => `agent-dossier-panel-${segment}` const panelId = (segment: DossierSegment) => `agent-dossier-panel-${segment}`
@@ -108,10 +126,10 @@ export function AgentDossier({ agent, activeSegment, onSegmentChange }: Props) {
> >
{activeSegment === 'beschrieb' && <AgentDescriptionTab key={agent.id} agent={agent} />} {activeSegment === 'beschrieb' && <AgentDescriptionTab key={agent.id} agent={agent} />}
{activeSegment === 'aufgaben' && <AgentTasksTab key={agent.id} agent={agent} />} {activeSegment === 'aufgaben' && <AgentTasksTab key={agent.id} agent={agent} />}
{activeSegment === 'kanaele' && <AgentChannelsTab key={agent.id} agent={agent} />} {activeSegment === 'kennzahlen' && <AgentMetricsTab key={agent.id} agent={agent} />}
{activeSegment === 'systeme' && <AgentSystemsTab key={agent.id} agent={agent} />} {activeSegment === 'kanaele' && <AgentConnectionsTab key={agent.id} agent={agent} />}
{activeSegment === 'einstellungen' && <AgentSettingsTab key={agent.id} agent={agent} />}
{activeSegment === 'protokoll' && <AgentProtocolTab key={agent.id} agent={agent} />} {activeSegment === 'protokoll' && <AgentProtocolTab key={agent.id} agent={agent} />}
{activeSegment === 'einstellungen' && <AgentSettingsTab key={agent.id} agent={agent} />}
</Box> </Box>
</> </>
) )
+43 -3
View File
@@ -4,7 +4,7 @@ import { AlertTriangle, Mail } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent' import type { TeamAgent } from '../../domain/teamAgent'
import { AgentStatus } from '../../domain/teamAgent' import { AgentStatus } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges' import { AgentStatusBadge } from './AgentBadges'
import { ConfirmDialog } from '../ui' import { ConfirmDialog } from '../ui'
import { useSetAgentActive } from '../../hooks/useTeamAgents' import { useSetAgentActive } from '../../hooks/useTeamAgents'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
@@ -14,6 +14,22 @@ interface Props {
agent: TeamAgent agent: TeamAgent
} }
/** Mehr als vier Stichworte liest im Kopfbereich niemand mehr. */
const MAX_RESPONSIBILITIES = 4
const LABEL_MAX_CHARS = 42
/**
* Verdichtet eine ausformulierte Zuständigkeit auf ein Stichwort.
* Schneidet am ersten Komma, sonst an der Wortgrenze — der volle Wortlaut
* bleibt als Titel am Element erhalten, es geht also keine Aussage verloren.
*/
function shortLabel(text: string): string {
const head = text.split(',')[0].trim()
if (head.length <= LABEL_MAX_CHARS) return head
const cut = head.slice(0, LABEL_MAX_CHARS)
return `${cut.slice(0, cut.lastIndexOf(' '))}`
}
function MetaField({ label, value }: { label: string; value: string }) { function MetaField({ label, value }: { label: string; value: string }) {
return ( return (
<Box> <Box>
@@ -65,8 +81,33 @@ export function AgentDossierHeader({ agent }: Props) {
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}> <Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
<AgentStatusBadge status={agent.status} /> <AgentStatusBadge status={agent.status} />
<AgentAutonomyBadge autonomy={agent.autonomyLevel} note={agent.autonomyNote} />
</Box> </Box>
{/* Kurzer Beschrieb und Zuständigkeiten stehen jetzt oben statt tief im
Dossier — das ist die Frage, die man beim Öffnen zuerst hat. */}
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 1.25, maxWidth: '70ch' }}>
{agent.shortDescription}
</Typography>
{agent.responsibilities.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap', mt: 1.25 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, flexShrink: 0 }}>
Zuständig für:
</Typography>
{agent.responsibilities.slice(0, MAX_RESPONSIBILITIES).map((r, i) => (
<Box key={r} sx={{ display: 'inline-flex', alignItems: 'baseline', gap: 1 }}>
{i > 0 && <Typography aria-hidden variant="caption" sx={{ color: DS_TEXT.disabled }}>·</Typography>}
<Typography
title={r}
variant="caption"
sx={{ color: DS_TEXT.secondary, fontWeight: 600 }}
>
{shortLabel(r)}
</Typography>
</Box>
))}
</Box>
)}
</Box> </Box>
<FormControlLabel <FormControlLabel
@@ -96,7 +137,6 @@ export function AgentDossierHeader({ agent }: Props) {
> >
<MetaField label="Personalnummer" value={agent.personnelNumber} /> <MetaField label="Personalnummer" value={agent.personnelNumber} />
<MetaField label="Abteilung" value={agent.department} /> <MetaField label="Abteilung" value={agent.department} />
<MetaField label="Autonomiegrad" value={agent.autonomyNote} />
<MetaField <MetaField
label="Letzter Lauf" label="Letzter Lauf"
value={agent.lastRun ? formatTeamDateTime(agent.lastRun) : 'Noch nicht gelaufen'} value={agent.lastRun ? formatTeamDateTime(agent.lastRun) : 'Noch nicht gelaufen'}
+71
View File
@@ -0,0 +1,71 @@
import { memo } from 'react'
import { Box, Typography } from '@mui/material'
import type { AgentMetric, TeamAgent } from '../../domain/teamAgent'
import { EmptyState } from '../ui'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
/**
* Reiter «Kennzahlen».
*
* Zeigt ausschliesslich die Kennzahlen, die im Personalblatt des Mitarbeitenden
* bereits hinterlegt sind. Es werden keine Werte berechnet, hochgerechnet oder
* ergänzt — was der Katalog nicht führt, steht hier auch nicht.
*/
const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric }) {
return (
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 2,
minWidth: 0,
}}
>
<Typography
sx={{
fontWeight: 700,
fontSize: '1.5rem',
lineHeight: 1.15,
color: DS_TEXT.primary,
fontVariantNumeric: 'tabular-nums',
}}
>
{metric.value}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600, mt: 0.5 }}>
{metric.label}
</Typography>
{metric.hint && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mt: 0.25 }}>
{metric.hint}
</Typography>
)}
</Box>
)
})
export function AgentMetricsTab({ agent }: { agent: TeamAgent }) {
if (agent.metrics.length === 0) {
return (
<EmptyState
title="Keine Kennzahlen hinterlegt"
description={`Für ${agent.name} führt das Personalblatt derzeit keine Kennzahlen.`}
/>
)
}
return (
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' },
}}
>
{agent.metrics.map((metric) => (
<MetricTile key={metric.id} metric={metric} />
))}
</Box>
)
}
+2 -9
View File
@@ -13,7 +13,7 @@ import { Box, Button, Collapse, FormControlLabel, InputAdornment, MenuItem, Skel
import { AlertTriangle, Building2, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Info, Radio, Search, UserRound, XCircle } from 'lucide-react' import { AlertTriangle, Building2, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Info, Radio, Search, UserRound, XCircle } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent' import type { TeamAgent } from '../../domain/teamAgent'
import type { AgentProtocolEntry } from '../../domain/agentProtocol' import type { AgentProtocolEntry } from '../../domain/agentProtocol'
import { AgentProtocolEventType, AgentProtocolStatus, AgentTriggerSource } from '../../domain/agentProtocol' import { AgentProtocolStatus, AgentTriggerSource } from '../../domain/agentProtocol'
import { AgentPeriod } from '../../domain/agentFilters' import { AgentPeriod } from '../../domain/agentFilters'
import type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider' import type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider'
import { useAgentProtocol } from '../../hooks/useAgentProtocol' import { useAgentProtocol } from '../../hooks/useAgentProtocol'
@@ -145,7 +145,6 @@ const ProtocolEntryRow = memo(function ProtocolEntryRow({ entry, expanded, isLas
export function AgentProtocolTab({ agent }: { agent: TeamAgent }) { export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
const [period, setPeriod] = useState<AgentPeriod>(AgentPeriod.ALL) const [period, setPeriod] = useState<AgentPeriod>(AgentPeriod.ALL)
const [eventType, setEventType] = useState<AgentProtocolEventType | 'ALL'>('ALL')
const [status, setStatus] = useState<AgentProtocolStatus | 'ALL'>('ALL') const [status, setStatus] = useState<AgentProtocolStatus | 'ALL'>('ALL')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [onlyApprovals, setOnlyApprovals] = useState(false) const [onlyApprovals, setOnlyApprovals] = useState(false)
@@ -154,11 +153,10 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
const filters = useMemo<AgentProtocolFilters>(() => ({ const filters = useMemo<AgentProtocolFilters>(() => ({
agentId: agent.id, agentId: agent.id,
period, period,
eventType: eventType === 'ALL' ? undefined : eventType,
status: status === 'ALL' ? undefined : status, status: status === 'ALL' ? undefined : status,
onlyApprovals: onlyApprovals || undefined, onlyApprovals: onlyApprovals || undefined,
search: search.trim() || undefined, search: search.trim() || undefined,
}), [agent.id, period, eventType, status, onlyApprovals, search]) }), [agent.id, period, status, onlyApprovals, search])
const { data: entries = [], isLoading, isError, refetch } = useAgentProtocol(filters) const { data: entries = [], isLoading, isError, refetch } = useAgentProtocol(filters)
const lastId = entries.length > 0 ? entries[entries.length - 1].id : null const lastId = entries.length > 0 ? entries[entries.length - 1].id : null
@@ -167,7 +165,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
setPeriod(AgentPeriod.ALL) setPeriod(AgentPeriod.ALL)
setEventType('ALL')
setStatus('ALL') setStatus('ALL')
setSearch('') setSearch('')
setOnlyApprovals(false) setOnlyApprovals(false)
@@ -190,10 +187,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) {
labels={AGENT_PERIOD_LABELS} allLabel={AGENT_PERIOD_LABELS.ALL} labels={AGENT_PERIOD_LABELS} allLabel={AGENT_PERIOD_LABELS.ALL}
onChange={(v) => setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)} onChange={(v) => setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)}
/> />
<FilterSelect
label="Ereignisart" value={eventType} options={Object.values(AgentProtocolEventType)}
labels={AGENT_PROTOCOL_EVENT_LABELS} allLabel="Alle Ereignisarten" onChange={setEventType}
/>
<FilterSelect <FilterSelect
label="Status" value={status} options={Object.values(AgentProtocolStatus)} label="Status" value={status} options={Object.values(AgentProtocolStatus)}
labels={AGENT_PROTOCOL_STATUS_LABELS} allLabel="Alle Status" onChange={setStatus} labels={AGENT_PROTOCOL_STATUS_LABELS} allLabel="Alle Status" onChange={setStatus}
+37 -36
View File
@@ -10,9 +10,8 @@
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, Switch, Typography } from '@mui/material'
import { Clock, Plug, RotateCcw, Save, ShieldCheck } from 'lucide-react' import { RotateCcw, Save, ShieldCheck } from 'lucide-react'
import type { AgentTask, TeamAgent } from '../../domain/teamAgent' 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 { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { GenericBadge } from '../shared/GenericBadge' import { GenericBadge } from '../shared/GenericBadge'
import { useSaveAgentTasks } from '../../hooks/useTeamAgents' import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
@@ -30,17 +29,6 @@ interface TaskRowProps {
* 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 }: 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( 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],
@@ -51,9 +39,9 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
component="li" component="li"
sx={{ sx={{
display: 'flex', display: 'flex',
gap: 1.5, gap: 1,
alignItems: 'flex-start', alignItems: 'flex-start',
p: 1.75, p: 1.25,
border: `1px solid ${DS_BORDER.default}`, border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2, borderRadius: 2,
bgcolor: task.enabled ? DS_BG.surface : DS_BG.subtle, bgcolor: task.enabled ? DS_BG.surface : DS_BG.subtle,
@@ -61,7 +49,7 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
}} }}
> >
{/* Schalter mit Zustand als Text — Farbe allein trägt keinen Status (§18) */} {/* Schalter mit Zustand als Text — Farbe allein trägt keinen Status (§18) */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, width: 76 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, width: 60 }}>
<Switch <Switch
checked={task.enabled} checked={task.enabled}
disabled={busy} disabled={busy}
@@ -83,32 +71,30 @@ 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.9375rem', color: DS_TEXT.primary }}> <Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary }}>
{task.title} {task.title}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.25 }}> {/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle
Wortlaut steht am Element. */}
<Typography
title={task.description}
variant="body2"
sx={{
color: DS_TEXT.secondary,
fontSize: '0.8125rem',
mt: 0.25,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{task.description} {task.description}
</Typography> </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 && ( {task.requiresApproval && (
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 0.75 }}>
<GenericBadge <GenericBadge
label="Freigabe erforderlich" label="Freigabe erforderlich"
semanticVariant="confidenceMedium" semanticVariant="confidenceMedium"
@@ -224,7 +210,22 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
Für {agent.name} sind noch keine Aufgaben hinterlegt. Für {agent.name} sind noch keine Aufgaben hinterlegt.
</Typography> </Typography>
) : ( ) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, mt: 1.75, p: 0, display: 'grid', gap: 1.25 }}> <Box
component="ul"
sx={{
listStyle: 'none',
m: 0,
mt: 1.75,
p: 0,
display: 'grid',
gap: 1.25,
gridTemplateColumns: {
xs: '1fr',
md: 'repeat(2, minmax(0, 1fr))',
xl: 'repeat(3, minmax(0, 1fr))',
},
}}
>
{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} />
))} ))}
+11 -41
View File
@@ -10,7 +10,7 @@
* Stattdessen trägt jede Kategorie ein neutrales, einheitliches Sinnbild. * Stattdessen trägt jede Kategorie ein neutrales, einheitliches Sinnbild.
*/ */
import { memo, useCallback, useMemo } from 'react' import { memo, useCallback } from 'react'
import { Box, Button, Switch, Typography } from '@mui/material' import { Box, Button, Switch, Typography } from '@mui/material'
import { import {
Calendar, Calendar,
@@ -27,7 +27,6 @@ import type { LucideIcon } from 'lucide-react'
import type { AgentConnection } from '../../domain/agentConnection' import type { AgentConnection } from '../../domain/agentConnection'
import { AgentConnectionCategory } from '../../domain/agentConnection' import { AgentConnectionCategory } from '../../domain/agentConnection'
import { AgentConnectionStatus } from '../../domain/teamAgent' import { AgentConnectionStatus } from '../../domain/teamAgent'
import { AgentConnectionStatusBadge } from './AgentBadges'
import { AgentAvatarGroup } from './AgentAvatarGroup' import { AgentAvatarGroup } from './AgentAvatarGroup'
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants' import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
@@ -45,28 +44,6 @@ const CATEGORY_ICON: Record<AgentConnectionCategory, LucideIcon> = {
[AgentConnectionCategory.PUBLIC_SOURCES]: Globe, [AgentConnectionCategory.PUBLIC_SOURCES]: Globe,
} }
/** Abkürzungen wie «St.» oder «z. B.» beenden keinen Satz — kurze Wörter überspringen. */
const ABBREVIATION_MAX_LENGTH = 3
/**
* Erster Satz eines Beschriebs. Die Daten selbst bleiben unangetastet; nur die
* Karte zeigt weniger, damit alle Karten gleich ruhig wirken.
*/
function firstSentence(text: string): string {
const trimmed = text.trim()
for (let i = 0; i < trimmed.length; i += 1) {
const char = trimmed[i]
if (char !== '.' && char !== '!' && char !== '?') continue
const next = trimmed[i + 1]
if (next !== undefined && next !== ' ' && next !== '\n') continue
const head = trimmed.slice(0, i)
const word = head.slice(head.lastIndexOf(' ') + 1)
if (word.length <= ABBREVIATION_MAX_LENGTH) continue
return trimmed.slice(0, i + 1)
}
return trimmed
}
/** /**
* Sinnbild der Kategorie in ruhiger Fläche. Auch von der Kurzliste auf der * Sinnbild der Kategorie in ruhiger Fläche. Auch von der Kurzliste auf der
* Teamübersicht genutzt, damit beide Ansichten dasselbe Zeichen führen. * Teamübersicht genutzt, damit beide Ansichten dasselbe Zeichen führen.
@@ -114,7 +91,6 @@ export const ConnectionCard = memo(function ConnectionCard({
}: Props) { }: Props) {
const isConnected = connection.status === AgentConnectionStatus.CONNECTED const isConnected = connection.status === AgentConnectionStatus.CONNECTED
const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP
const purpose = useMemo(() => firstSentence(connection.description), [connection.description])
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
if (isConnected) onDisconnect(connection.id) if (isConnected) onDisconnect(connection.id)
@@ -160,6 +136,14 @@ export const ConnectionCard = memo(function ConnectionCard({
{connection.name} {connection.name}
</Typography> </Typography>
{/* Zustand als Text neben dem Regler — Farbe allein trägt keinen Status. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: isConnected ? DS_TEXT.success : DS_TEXT.muted }}
>
{isRoadmap ? 'Geplant' : isConnected ? 'Aktiv' : 'Inaktiv'}
</Typography>
<Switch <Switch
size="small" size="small"
checked={isConnected} checked={isConnected}
@@ -168,24 +152,10 @@ export const ConnectionCard = memo(function ConnectionCard({
slotProps={{ input: { 'aria-label': switchLabel } }} slotProps={{ input: { 'aria-label': switchLabel } }}
/> />
</Box> </Box>
<Typography
variant="body2"
title={purpose}
sx={{
color: DS_TEXT.secondary, fontSize: '0.8125rem', lineHeight: 1.4,
display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{purpose}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<AgentConnectionStatusBadge status={connection.status} />
<AgentAvatarGroup agentIds={connection.usedByAgentIds} label="Genutzt durch" size={20} />
</Box> </Box>
<AgentAvatarGroup agentIds={connection.usedByAgentIds} label="Genutzt durch" size={20} />
<Box <Box
sx={{ sx={{
mt: 'auto', mt: 'auto',
+64 -58
View File
@@ -10,76 +10,86 @@ interface Props {
onSelect: (agentId: string) => void onSelect: (agentId: string) => void
} }
/** Grad Drehung je gezogenem Pixel. Ein voller Durchlauf braucht so gut 700 px. */ /** Grad Drehung je gezogenem Pixel. Bewusst träge — ein Wisch soll nicht durchs Team jagen. */
const DRAG_DEGREES_PER_PX = 0.5 const DRAG_DEGREES_PER_PX = 0.22
/** Darunter gilt eine Bewegung als Zittern und wird verworfen. */
const DRAG_THRESHOLD_PX = 10
/** Bis zu dieser Zugweite wechselt höchstens ein Platz. */
const SINGLE_STEP_LIMIT_PX = 90
/** Durchmesser des gedachten Vollkreises; sichtbar ist nur die untere Hälfte. */ /** Durchmesser des gedachten Vollkreises; sichtbar ist nur die untere Hälfte. */
const WHEEL_DIAMETER = 720 const WHEEL_DIAMETER = 460
const AVATAR_PX = 66 const AVATAR_PX = 52
const VISIBLE_HEIGHT = WHEEL_DIAMETER / 2 + 34
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
/** /**
* Drehbares Agentenrad der Organigramm-Ansicht. * Drehbares Agentenrad der Organigramm-Ansicht.
* *
* Der gedachte Vollkreis ragt nach oben aus dem Container heraus und wird von * Geometrie: `rotate(a) translateY(r)` schiebt ein Element bei a = 0 nach UNTEN.
* ihm abgeschnitten — sichtbar bleibt der untere Halbkreis. Deshalb wird nichts * Ein Knoten mit Grundwinkel `i · step` steht also genau dann unten in der Mitte,
* am Bild zugeschnitten, sondern allein per `overflow: hidden` beschnitten. * wenn das Rad um `i · step` gedreht ist. Genau das war der Fehler zuvor: mit
* `180 i · step` landete der gewählte Mitarbeitende oben, das Dossier darunter
* gehörte folglich zum falschen Agenten.
* *
* Aktiv ist immer, wer unten in der Mitte steht. Während des Ziehens läuft nur * Der gedachte Vollkreis ragt nach oben aus dem Container heraus und wird von
* ein lokaler Drehwert mit; die Auswahl wechselt erst beim Einrasten. Sonst * ihm abgeschnitten — es wird nichts am Bild zugeschnitten, nur beschnitten.
* würde bei jeder Mausbewegung das gesamte Dossier darunter neu aufgebaut. *
* Während des Ziehens läuft ausschliesslich ein lokaler Drehwert mit; die
* Auswahl wechselt erst beim Einrasten. Sonst würde bei jeder Mausbewegung das
* gesamte Dossier darunter neu aufgebaut.
*/ */
export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) { export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
const step = agents.length > 0 ? 360 / agents.length : 360 const step = agents.length > 0 ? 360 / agents.length : 360
const selectedIndex = Math.max(0, agents.findIndex(a => a.id === selectedId)) const selectedIndex = Math.max(0, agents.findIndex(a => a.id === selectedId))
const [dragDeg, setDragDeg] = useState(0) const [dragDeg, setDragDeg] = useState(0)
// Zwei Grössen für denselben Vorgang: die Ref trägt den Startpunkt durch die
// Pointer-Ereignisse, der State steuert die Darstellung. Eine Ref im Render zu
// lesen wäre regelwidrig — die Änderung löst kein neues Rendern aus.
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const dragging = useRef<{ startX: number } | null>(null) const drag = useRef<{ startX: number } | null>(null)
// Drehung, die den gewählten Agenten nach unten in die Mitte bringt. // Drehung, die den gewählten Mitarbeitenden nach unten in die Mitte bringt.
const baseDeg = 180 - selectedIndex * step const baseDeg = -selectedIndex * step
const rotation = baseDeg + dragDeg const rotation = baseDeg + dragDeg
const positions = useMemo( const angles = useMemo(() => agents.map((_, i) => i * step), [agents, step])
() => agents.map((_, i) => i * step),
[agents, step],
)
const commitNearest = useCallback( /** Wie viele Plätze eine Zugweite bedeutet — kurze Züge höchstens einen. */
(currentRotation: number) => { const stepsFor = useCallback(
if (agents.length === 0) return (dx: number) => {
// Welcher Index steht dieser Drehung am nächsten? if (Math.abs(dx) < DRAG_THRESHOLD_PX) return 0
const raw = (180 - currentRotation) / step const raw = -(dx * DRAG_DEGREES_PER_PX) / step
const index = ((Math.round(raw) % agents.length) + agents.length) % agents.length const rounded = Math.round(raw)
const next = agents[index] if (Math.abs(dx) <= SINGLE_STEP_LIMIT_PX) return clamp(rounded, -1, 1)
if (next && next.id !== selectedId) onSelect(next.id) return rounded
setDragDeg(0)
}, },
[agents, step, selectedId, onSelect], [step],
) )
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => { const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
dragging.current = { startX: e.clientX } drag.current = { startX: e.clientX }
setIsDragging(true) setIsDragging(true)
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture(e.pointerId)
}, []) }, [])
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => { const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return if (!drag.current) return
setDragDeg((e.clientX - dragging.current.startX) * DRAG_DEGREES_PER_PX) setDragDeg((e.clientX - drag.current.startX) * DRAG_DEGREES_PER_PX)
}, []) }, [])
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return if (!drag.current) return
const offset = (e.clientX - dragging.current.startX) * DRAG_DEGREES_PER_PX const dx = e.clientX - drag.current.startX
dragging.current = null drag.current = null
setIsDragging(false) setIsDragging(false)
commitNearest(baseDeg + offset) setDragDeg(0)
const moved = stepsFor(dx)
if (moved === 0 || agents.length === 0) return
const next = agents[((selectedIndex + moved) % agents.length + agents.length) % agents.length]
if (next && next.id !== selectedId) onSelect(next.id)
}, },
[baseDeg, commitNearest], [stepsFor, agents, selectedIndex, selectedId, onSelect],
) )
const move = useCallback( const move = useCallback(
@@ -93,13 +103,8 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); move(1) }
e.preventDefault() else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
move(1)
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
move(-1)
}
}, },
[move], [move],
) )
@@ -110,7 +115,7 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
<Box <Box
role="listbox" role="listbox"
tabIndex={0} tabIndex={0}
aria-label="Digitale Mitarbeitende, mit den Pfeiltasten wechseln" aria-label="Digitale Mitarbeitende"
aria-activedescendant={active ? `wheel-${active.id}` : undefined} aria-activedescendant={active ? `wheel-${active.id}` : undefined}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
@@ -119,7 +124,7 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
onPointerCancel={handlePointerUp} onPointerCancel={handlePointerUp}
sx={{ sx={{
position: 'relative', position: 'relative',
height: WHEEL_DIAMETER / 2 + 24, height: VISIBLE_HEIGHT,
overflow: 'hidden', overflow: 'hidden',
cursor: 'grab', cursor: 'grab',
touchAction: 'pan-y', touchAction: 'pan-y',
@@ -133,21 +138,21 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
sx={{ sx={{
position: 'absolute', position: 'absolute',
left: '50%', left: '50%',
// Der Kreis ragt nach oben hinaus; der Container schneidet ihn ab. top: -WHEEL_DIAMETER / 2 + 22,
top: -WHEEL_DIAMETER / 2 + 24,
width: WHEEL_DIAMETER, width: WHEEL_DIAMETER,
height: WHEEL_DIAMETER, height: WHEEL_DIAMETER,
ml: `${-WHEEL_DIAMETER / 2}px`, ml: `${-WHEEL_DIAMETER / 2}px`,
borderRadius: '50%', borderRadius: '50%',
border: `1px solid ${DS_BORDER.muted}`, border: `1px solid ${DS_BORDER.muted}`,
transform: `rotate(${rotation}deg)`, transform: `rotate(${rotation}deg)`,
transition: isDragging ? 'none' : 'transform 0.35s ease', transition: isDragging ? 'none' : 'transform 250ms ease-out',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' }, '@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}} }}
> >
{agents.map((agent, index) => { {agents.map((agent, index) => {
const angle = positions[index] const angle = angles[index]
const isActive = index === selectedIndex const isActive = index === selectedIndex
const size = isActive ? AVATAR_PX + 12 : AVATAR_PX
return ( return (
<Box <Box
key={agent.id} key={agent.id}
@@ -161,18 +166,19 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
top: '50%', top: '50%',
// erst auf den Radius schieben, dann die Raddrehung ausgleichen, // erst auf den Radius schieben, dann die Raddrehung ausgleichen,
// damit die Portraits aufrecht stehen // damit die Portraits aufrecht stehen
transform: `rotate(${angle}deg) translateY(${WHEEL_DIAMETER / 2 - AVATAR_PX}px) rotate(${-angle - rotation}deg) translate(-50%, -50%)`, transform: `rotate(${angle}deg) translateY(${WHEEL_DIAMETER / 2 - AVATAR_PX - 6}px) rotate(${-angle - rotation}deg) translate(-50%, -50%)`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
gap: 0.5, gap: 0.4,
width: 150, width: 132,
cursor: 'pointer', cursor: 'pointer',
opacity: isActive ? 1 : 0.72,
}} }}
> >
<AgentAvatar <AgentAvatar
agent={agent} agent={agent}
size={isActive ? AVATAR_PX + 14 : AVATAR_PX} size={size}
loading="eager" loading="eager"
ringColor={isActive ? BADGE_COLORS.gold : DS_BORDER.default} ringColor={isActive ? BADGE_COLORS.gold : DS_BORDER.default}
ringWidth={isActive ? 3 : 1.5} ringWidth={isActive ? 3 : 1.5}
@@ -180,7 +186,7 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
<Typography <Typography
sx={{ sx={{
fontWeight: isActive ? 700 : 600, fontWeight: isActive ? 700 : 600,
fontSize: isActive ? '0.9375rem' : '0.8125rem', fontSize: isActive ? '0.875rem' : '0.75rem',
color: isActive ? DS_TEXT.primary : DS_TEXT.secondary, color: isActive ? DS_TEXT.primary : DS_TEXT.secondary,
textAlign: 'center', textAlign: 'center',
lineHeight: 1.2, lineHeight: 1.2,
@@ -189,7 +195,7 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
{agent.name} {agent.name}
</Typography> </Typography>
{isActive && ( {isActive && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}> <Typography variant="caption" sx={{ color: DS_TEXT.secondary, textAlign: 'center', lineHeight: 1.2 }}>
{agent.role} {agent.role}
</Typography> </Typography>
)} )}
@@ -202,7 +208,7 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) {
variant="caption" variant="caption"
sx={{ position: 'absolute', left: 16, bottom: 8, color: DS_TEXT.muted }} sx={{ position: 'absolute', left: 16, bottom: 8, color: DS_TEXT.muted }}
> >
Ziehen oder Pfeiltasten, um zu wechseln {agents.length} Mitarbeitende Ziehen zum Wechseln
</Typography> </Typography>
</Box> </Box>
) )
+18 -2
View File
@@ -15,6 +15,12 @@ interface Props {
level: AgentLevel level: AgentLevel
} }
/** Verkleinerung nach Anzahl sichtbarer Ringe. */
const RING_COUNT_SCALE: Record<number, number> = { 1: 0.72, 2: 0.84, 3: 0.92, 4: 1 }
/** Platz für Seitentitel, Stufenfilter und Aussenabstände. */
const VIEWPORT_RESERVE_PX = 250
/** Misst die Containerbreite, damit Portraits und Schrift mitskalieren. */ /** Misst die Containerbreite, damit Portraits und Schrift mitskalieren. */
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] { function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
@@ -61,6 +67,15 @@ export function TeamOverviewRing({ level }: Props) {
const scale = width / RING_REFERENCE_PX const scale = width / RING_REFERENCE_PX
const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level) const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level)
/**
* Je mehr Ringe sichtbar sind, desto kleiner die Grafik: sonst wächst sie mit
* jeder Stufe aus dem Bild heraus und die Seite wird länger. Zusätzlich
* begrenzt die Viewport-Höhe, damit das Kernteam ohne Scrollen vollständig
* sichtbar bleibt.
*/
const ringScale = RING_COUNT_SCALE[visibleUpTo + 1] ?? RING_COUNT_SCALE[4]
const boxSize = `min(100%, ${Math.round(RING_REFERENCE_PX * ringScale)}px, calc(100vh - ${VIEWPORT_RESERVE_PX}px))`
const agentsByLevel = useMemo(() => { const agentsByLevel = useMemo(() => {
const map = new Map<AgentLevel, AgentDirectoryEntry[]>() const map = new Map<AgentLevel, AgentDirectoryEntry[]>()
for (const spec of RING_SPECS) { for (const spec of RING_SPECS) {
@@ -104,10 +119,11 @@ export function TeamOverviewRing({ level }: Props) {
aria-label="Digitale Belegschaft" aria-label="Digitale Belegschaft"
sx={{ sx={{
position: 'relative', position: 'relative',
width: '100%', width: boxSize,
maxWidth: RING_REFERENCE_PX,
aspectRatio: '1 / 1', aspectRatio: '1 / 1',
mx: 'auto', mx: 'auto',
transition: 'width 0.35s ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}} }}
> >
{RING_SPECS.map((spec, index) => ( {RING_SPECS.map((spec, index) => (
-8
View File
@@ -1,7 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { Info } from 'lucide-react'
import { AGENT_DEMO_NOTICE } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
interface Props { interface Props {
@@ -57,12 +55,6 @@ export function TeamPageHeader({ title, description, actions, tabs }: Props) {
</Typography> </Typography>
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.75 }}>
<Info size={13} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{AGENT_DEMO_NOTICE}
</Typography>
</Box>
</Box> </Box>
{actions && ( {actions && (
+61 -145
View File
@@ -1,53 +1,39 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Button } from '@mui/material' import { Box, Button } from '@mui/material'
import { Check, CheckCheck, FileSearch, MessageSquareReply, Pencil, Scale, X } from 'lucide-react' import { Building2, Check, FileSearch, X } from 'lucide-react'
import type { AgentWorkItem, AgentEditableField } from '../../domain/agentWorkItem' import type { AgentWorkItem } from '../../domain/agentWorkItem'
import { AgentWorkItemAction } from '../../domain/agentWorkItem'
import { ConfirmDialog } from '../ui' import { ConfirmDialog } from '../ui'
import { RejectWorkItemDialog, EditWorkItemDialog, AnswerQueryDialog } from './WorkItemActionDialogs' import { RejectWorkItemDialog } from './WorkItemActionDialogs'
import { import { useApproveWorkItem, useRejectWorkItem } from '../../hooks/useAgentWorkItems'
useApproveWorkItem,
useRejectWorkItem,
useSaveWorkItemEdit,
useAnswerWorkItemQuery,
useMarkWorkItemDone,
} from '../../hooks/useAgentWorkItems'
import { DS_BG, DS_BORDER } from '../../lib/ds' import { DS_BG, DS_BORDER } from '../../lib/ds'
type OpenDialog = 'none' | 'approve' | 'decide' | 'reject' | 'edit' | 'answer'
interface Props { interface Props {
item: AgentWorkItem item: AgentWorkItem
/** Springt zu den Fundstellen — «Quelle öffnen» ohne echtes Zielsystem. */ /** Springt im Drawer zum Abschnitt «Quellen» — öffnet keine neue Seite. */
onOpenSource: () => void onShowSources: () => void
/** Öffnet das zugehörige Objekt in «Meine Objekte». */
onOpenProperty: () => void
} }
/** /**
* Aktionsleiste eines Vorgangs (§5.6). * Aktionsleiste eines Vorgangs.
* *
* Freigabe und Entscheidung laufen bewusst über einen Bestätigungsdialog: sie * Pendent: Freigeben, Zurückweisen, Quellen anzeigen, Inserat anzeigen.
* sind irreversibel und dürfen nicht versehentlich ausgelöst werden (§17.3). * Erledigt: nur noch der Weg zum Objekt — entschieden ist entschieden.
* Die 1-Klick-Bestätigung ist die eine Ausnahme — sie ist als schneller Weg *
* fachlich so vorgesehen und im Katalog explizit für Reto vorgesehen. * Die Freigabe läuft über einen Bestätigungsdialog: sie ist irreversibel und
* darf nicht versehentlich ausgelöst werden.
*/ */
export function WorkItemActions({ item, onOpenSource }: Props) { export function WorkItemActions({ item, onShowSources, onOpenProperty }: Props) {
const [dialog, setDialog] = useState<OpenDialog>('none') const [confirmApprove, setConfirmApprove] = useState(false)
const [confirmReject, setConfirmReject] = useState(false)
const approve = useApproveWorkItem() const approve = useApproveWorkItem()
const reject = useRejectWorkItem() const reject = useRejectWorkItem()
const saveEdit = useSaveWorkItemEdit() const busy = approve.isPending || reject.isPending
const answer = useAnswerWorkItemQuery()
const markDone = useMarkWorkItemDone()
const busy = const hasSources = item.sourceReferences.length > 0
approve.isPending || reject.isPending || saveEdit.isPending || answer.isPending || markDone.isPending const hasProperty = !!item.objectId
const can = (action: string) => item.availableActions.includes(action as never)
const close = () => setDialog('none')
const runApprove = () => {
approve.mutate(item.id, { onSuccess: close })
}
return ( return (
<> <>
@@ -62,158 +48,88 @@ export function WorkItemActions({ item, onOpenSource }: Props) {
flexShrink: 0, flexShrink: 0,
}} }}
> >
{can(AgentWorkItemAction.ONE_CLICK_CONFIRM) && ( {item.requiresDecision ? (
<Button <>
variant="contained"
size="small"
disabled={busy}
startIcon={<CheckCheck size={15} />}
onClick={runApprove}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Mit einem Klick bestätigen
</Button>
)}
{can(AgentWorkItemAction.APPROVE) && (
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
disabled={busy} disabled={busy}
startIcon={<Check size={15} />} startIcon={<Check size={15} />}
onClick={() => setDialog('approve')} onClick={() => setConfirmApprove(true)}
sx={{ textTransform: 'none', fontWeight: 600 }} sx={{ textTransform: 'none', fontWeight: 600 }}
> >
Freigeben Freigeben
</Button> </Button>
)}
{can(AgentWorkItemAction.DECIDE) && (
<Button
variant="contained"
size="small"
disabled={busy}
startIcon={<Scale size={15} />}
onClick={() => setDialog('decide')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Entscheidung treffen
</Button>
)}
{can(AgentWorkItemAction.ANSWER_QUERY) && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<MessageSquareReply size={15} />}
onClick={() => setDialog('answer')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Rückfrage beantworten
</Button>
)}
{can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<Pencil size={15} />}
onClick={() => setDialog('edit')}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Anpassen
</Button>
)}
{can(AgentWorkItemAction.REJECT) && (
<Button <Button
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
disabled={busy} disabled={busy}
startIcon={<X size={15} />} startIcon={<X size={15} />}
onClick={() => setDialog('reject')} onClick={() => setConfirmReject(true)}
sx={{ textTransform: 'none', fontWeight: 600 }} sx={{ textTransform: 'none', fontWeight: 600 }}
> >
Zurückweisen Zurückweisen
</Button> </Button>
)}
{can(AgentWorkItemAction.MARK_DONE) && ( {hasSources && (
<Button
variant="outlined"
size="small"
disabled={busy}
startIcon={<Check size={15} />}
onClick={() => markDone.mutate(item.id)}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Als erledigt markieren
</Button>
)}
{can(AgentWorkItemAction.OPEN_SOURCE) && item.sourceReferences.length > 0 && (
<Button <Button
size="small" size="small"
startIcon={<FileSearch size={15} />} startIcon={<FileSearch size={15} />}
onClick={onOpenSource} onClick={onShowSources}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Quellen anzeigen
</Button>
)}
{hasProperty && (
<Button
size="small"
startIcon={<Building2 size={15} />}
onClick={onOpenProperty}
sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }} sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
> >
Quelle öffnen Inserat anzeigen
</Button> </Button>
)} )}
</>
) : (
hasProperty && (
<Button
variant="outlined"
size="small"
startIcon={<Building2 size={15} />}
onClick={onOpenProperty}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Objekt anzeigen
</Button>
)
)}
</Box> </Box>
<ConfirmDialog <ConfirmDialog
open={dialog === 'approve'} open={confirmApprove}
title="Vorgang freigeben" title="Vorgang freigeben"
message={`«${item.title}» wird freigegeben und wechselt zu den erledigten Aufträgen. Die Freigabe wird protokolliert und lässt sich nicht rückgängig machen.`} message={`«${item.title}» wird freigegeben und wechselt zu den erledigten Aufträgen. Die Freigabe wird protokolliert und lässt sich nicht rückgängig machen.`}
confirmLabel="Freigeben" confirmLabel="Freigeben"
onConfirm={runApprove} onConfirm={() => {
onCancel={close} approve.mutate(item.id, { onSuccess: () => setConfirmApprove(false) })
/> }}
onCancel={() => setConfirmApprove(false)}
<ConfirmDialog
open={dialog === 'decide'}
title="Entscheidung bestätigen"
message={
item.decisionQuestion
? `${item.decisionQuestion} Mit der Bestätigung wird der vorgeschlagene Weg freigegeben und protokolliert.`
: `«${item.title}» wird entschieden und protokolliert.`
}
confirmLabel="Bestätigen"
onConfirm={runApprove}
onCancel={close}
/> />
<RejectWorkItemDialog <RejectWorkItemDialog
open={dialog === 'reject'} open={confirmReject}
title={item.title} title={item.title}
busy={reject.isPending} busy={reject.isPending}
onCancel={close} onCancel={() => setConfirmReject(false)}
onConfirm={(reason) => reject.mutate({ id: item.id, reason }, { onSuccess: close })} onConfirm={(reason) =>
/> reject.mutate({ id: item.id, reason }, { onSuccess: () => setConfirmReject(false) })
<EditWorkItemDialog
open={dialog === 'edit'}
title={item.title}
fields={item.editableFields ?? []}
busy={saveEdit.isPending}
onCancel={close}
onConfirm={(fields: AgentEditableField[]) =>
saveEdit.mutate({ id: item.id, fields }, { onSuccess: close })
} }
/> />
<AnswerQueryDialog
open={dialog === 'answer'}
question={item.decisionQuestion ?? item.title}
busy={answer.isPending}
onCancel={close}
onConfirm={(text) => answer.mutate({ id: item.id, answer: text }, { onSuccess: close })}
/>
</> </>
) )
} }
+30 -123
View File
@@ -1,22 +1,10 @@
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import type { ReactNode } from 'react'
import { Box, Tooltip, Typography } from '@mui/material' import { Box, Tooltip, Typography } from '@mui/material'
import { AlertTriangle, Building2, HelpCircle, Radio } from 'lucide-react' import type { AgentWorkItem } from '../../domain/agentWorkItem'
import type { AgentWorkItem, AgentWorkItemPriority } from '../../domain/agentWorkItem'
import {
AgentWorkItemPriority as Priority,
AgentWorkItemStatus as WorkItemStatus,
} from '../../domain/agentWorkItem'
import type { TeamAgent } from '../../domain/teamAgent' import type { TeamAgent } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { AgentWorkItemStatusBadge } from './AgentBadges'
import {
AGENT_CHANNEL_LABELS,
AGENT_PRIORITY_LABELS,
AGENT_WORK_ITEM_KIND_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime, formatTeamRelative } from '../../lib/teamClock' import { formatTeamDateTime } from '../../lib/teamClock'
interface Props { interface Props {
item: AgentWorkItem item: AgentWorkItem
@@ -27,66 +15,28 @@ interface Props {
const AVATAR_PX = 44 const AVATAR_PX = 44
/**
* Nur die beiden Ausnahmestufen tragen überhaupt eine Farbe. «Mittel» und
* «Niedrig» sind der Normalfall und werden gar nicht angezeigt — eine Angabe,
* die auf fast jeder Karte steht, trägt keine Information.
*/
const PRIORITY_ACCENT: Partial<Record<AgentWorkItemPriority, string>> = {
[Priority.CRITICAL]: DS_TEXT.error,
[Priority.HIGH]: DS_TEXT.warning,
}
/** Dezentes Trennzeichen statt eingefärbter Flächen zwischen den Fussangaben. */
function MetaDot() {
return (
<Typography aria-hidden component="span" variant="caption" sx={{ color: DS_TEXT.disabled }}>
·
</Typography>
)
}
function MetaEntry({ icon, text }: { icon?: ReactNode; text: string }) {
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
{icon}
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{text}
</Typography>
</Box>
)
}
/** /**
* Ein Eintrag im Bearbeitungsverlauf. * Ein Eintrag im Bearbeitungsverlauf.
* *
* `React.memo` ist hier nicht optional: die Liste rendert bis zu 14 Karten und * Bewusst auf das Nötigste reduziert: wer, was, wann. Priorität, Status,
* der Drawer verändert bei jeder Auswahl den Zustand der Seite — ohne Memo * Vorgangstyp und Kanal standen auf praktisch jeder Karte und trugen deshalb
* würde jede Auswahl die gesamte Liste neu zeichnen (CLAUDE.md §10.2). * keine Information mehr — sie sind in den Filtern und im Detail weiterhin
* erreichbar. Übrig bleibt, was die Karte beantworten soll: welches Problem
* liegt bei welcher Liegenschaft, und wer hat daran gearbeitet.
* *
* Farbführung: die Karte bleibt grundsätzlich neutral. Farbe erscheint nur * `React.memo` ist nicht optional: der Drawer verändert bei jeder Auswahl den
* dort, wo sie eine Abweichung meldet — Ausnahmepriorität, ein vom * Zustand der Seite, ohne Memo würde die ganze Liste neu zeichnen (CLAUDE.md §10.2).
* Erwartungswert abweichender Status und der ausgewählte Zustand.
*/ */
export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, onSelect }: Props) { export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, onSelect }: Props) {
const kindLabel = AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind
const channelLabel = AGENT_CHANNEL_LABELS[item.sourceChannel] ?? item.sourceChannel
const timestamp = item.completedAt ?? item.createdAt const timestamp = item.completedAt ?? item.createdAt
const priorityAccent = PRIORITY_ACCENT[item.priority]
// Auch ohne Objekt-ID anzeigen: Vorgänge ohne Objektbezug tragen im Label oft // «Kurze Problemstellung Name der Immobilie». Fehlt der Objektbezug, bleibt
// die entscheidende Einordnung («Zuordnung offen — Region Zug»). Hinge der // der Titel allein stehen statt mit einem leeren Gedankenstrich zu enden.
// Block allein an der ID, ginge sie verloren. const title = useMemo(
const objectText = useMemo( () => (item.objectLabel ? `${item.title} ${item.objectLabel}` : item.title),
() => [item.objectId, item.objectLabel].filter(Boolean).join(' · '), [item.title, item.objectLabel],
[item.objectId, item.objectLabel],
) )
// Erwartungswert: pendente Vorgänge stehen auf «Offen», erledigte auf
// «Abgeschlossen». Nur eine Abweichung davon ist eine Meldung wert.
const expectedStatus = item.requiresDecision ? WorkItemStatus.PENDING : WorkItemStatus.COMPLETED
const showStatus = item.status !== expectedStatus
return ( return (
<Box <Box
component="article" component="article"
@@ -104,7 +54,7 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
border: `1px solid ${selected ? DS_TEXT.brand : DS_BORDER.default}`, border: `1px solid ${selected ? DS_TEXT.brand : DS_BORDER.default}`,
borderRadius: 2, borderRadius: 2,
bgcolor: DS_BG.surface, bgcolor: DS_BG.surface,
p: 2.5, p: 2,
cursor: 'pointer', cursor: 'pointer',
boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card, boxShadow: selected ? DS_SHADOW.panel : DS_SHADOW.card,
transition: 'border-color 0.15s ease, box-shadow 0.15s ease', transition: 'border-color 0.15s ease, box-shadow 0.15s ease',
@@ -115,19 +65,7 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
> >
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}> <Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
{agent && ( {agent && (
<Tooltip <Tooltip arrow title={`${agent.name} · ${agent.role}`}>
arrow
title={
<Box>
<Typography variant="caption" sx={{ display: 'block', fontWeight: 700 }}>
{agent.name}
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
{agent.role}
</Typography>
</Box>
}
>
{/* Wrapper, weil MUI dem Kind eine Referenz anhängt — `AgentAvatar` {/* Wrapper, weil MUI dem Kind eine Referenz anhängt — `AgentAvatar`
ist memoisiert und nimmt selbst keine entgegen. */} ist memoisiert und nimmt selbst keine entgegen. */}
<Box sx={{ display: 'flex', flexShrink: 0 }}> <Box sx={{ display: 'flex', flexShrink: 0 }}>
@@ -137,7 +75,6 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
)} )}
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
{/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}> <Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary }}>
{agent?.name ?? 'Unbekannt'} {agent?.name ?? 'Unbekannt'}
@@ -150,54 +87,24 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected,
</Typography> </Typography>
</Box> </Box>
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 1 }}> <Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, mt: 0.75 }}>
{item.title} {title}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mt: 0.5 }}> <Typography
title={item.summary}
variant="body2"
sx={{
color: DS_TEXT.secondary,
mt: 0.5,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
}}
>
{item.summary} {item.summary}
</Typography> </Typography>
{/* Grund der Rückfrage — der wichtigste Satz bei pendenten Vorgängen.
Bewusst als schlichter Satz mit Icon, nicht als eingefärbte Fläche. */}
{item.requiresDecision && item.escalationReason && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', mt: 1.5 }}>
<HelpCircle size={14} color={DS_TEXT.muted} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{item.escalationReason}
</Typography>
</Box>
)}
{/* Fusszeile: Ausnahmen zuerst, danach Vorgangstyp, Objektbezug, Kanal */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap', mt: 2 }}>
{priorityAccent && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
<AlertTriangle size={13} color={priorityAccent} aria-hidden style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: priorityAccent, fontWeight: 700 }}>
{AGENT_PRIORITY_LABELS[item.priority] ?? item.priority}
</Typography>
</Box>
)}
{showStatus && <AgentWorkItemStatusBadge status={item.status} />}
<MetaEntry text={kindLabel} />
{objectText && (
<>
<MetaDot />
<MetaEntry icon={<Building2 size={12} color={DS_TEXT.muted} aria-hidden />} text={objectText} />
</>
)}
<MetaDot />
<MetaEntry icon={<Radio size={12} color={DS_TEXT.muted} aria-hidden />} text={channelLabel} />
<Typography variant="caption" sx={{ color: DS_TEXT.muted, ml: 'auto', whiteSpace: 'nowrap' }}>
{formatTeamRelative(timestamp)}
</Typography>
</Box>
</Box> </Box>
</Box> </Box>
</Box> </Box>
+55 -82
View File
@@ -1,30 +1,29 @@
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
import { Box, Divider, Drawer, IconButton, Typography } from '@mui/material' import { Box, Button, Drawer, IconButton, Typography } from '@mui/material'
import { HelpCircle, X } from 'lucide-react' import { ArrowUpRight, HelpCircle, X } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useTeamStore } from '../../stores/teamStore' import { useTeamStore } from '../../stores/teamStore'
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems' import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
import { useTeamAgents } from '../../hooks/useTeamAgents' import { useTeamAgents } from '../../hooks/useTeamAgents'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges'
import { WorkItemActions } from './WorkItemActions' import { WorkItemActions } from './WorkItemActions'
import { import { DetailFieldList, DetailSectionTitle, MessageThread, SourceReferenceList } from './WorkItemDetailSections'
DetailFieldList,
DetailSectionTitle,
MessageThread,
ProcessingStepList,
SourceReferenceList,
} from './WorkItemDetailSections'
import { PanelLoadingState } from '../ui' import { PanelLoadingState } from '../ui'
import { AGENT_WORK_ITEM_KIND_LABELS } from '../../lib/constants' import { ROUTES } 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'
import { formatTeamDateTime } from '../../lib/teamClock' import { formatTeamDateTime } from '../../lib/teamClock'
/** Das Anfragencenter führt den vollständigen Schriftverkehr. */
const ANFRAGENCENTER_ROUTE = '/supply/anfragen'
/** /**
* Detailansicht eines Vorgangs (§5.8). * Detailansicht eines Vorgangs.
* *
* Als Drawer statt eigener Route: der Nutzer arbeitet eine Liste ab und will * Bewusst schmal gehalten: Priorität, Status, Vorgangstyp, Verarbeitungsschritte
* nach jeder Entscheidung sofort wieder in ihr stehen — ein Seitenwechsel * und Aktionshistorie sind entfallen. Sie beschrieben die Maschine, nicht den
* würde bei jedem Vorgang den Kontext zerstören. * Fall. Übrig bleibt, was für die Entscheidung zählt — worum es geht, was der
* Mitarbeitende bisher hat, welche Nachricht es ausgelöst hat und woher die
* Angaben stammen.
*/ */
export function WorkItemDetailDrawer() { export function WorkItemDetailDrawer() {
const selectedId = useTeamStore(s => s.selectedWorkItemId) const selectedId = useTeamStore(s => s.selectedWorkItemId)
@@ -32,6 +31,7 @@ export function WorkItemDetailDrawer() {
const { data: item, isLoading } = useAgentWorkItem(selectedId) const { data: item, isLoading } = useAgentWorkItem(selectedId)
const { data: agents = [] } = useTeamAgents() const { data: agents = [] } = useTeamAgents()
const sourcesRef = useRef<HTMLDivElement | null>(null) const sourcesRef = useRef<HTMLDivElement | null>(null)
const navigate = useNavigate()
const agent = agents.find(a => a.id === item?.agentId) const agent = agents.find(a => a.id === item?.agentId)
const close = useCallback(() => setSelectedId(null), [setSelectedId]) const close = useCallback(() => setSelectedId(null), [setSelectedId])
@@ -40,14 +40,30 @@ export function WorkItemDetailDrawer() {
sourcesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) sourcesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, []) }, [])
// Vor die Callbacks gezogen: sonst steht `item` im Rumpf, aber nur
// `item?.objectId` in den Abhängigkeiten — die Memoisierung wäre unhaltbar.
const objectId = item?.objectId
// Der Objektbezug führt in «Meine Objekte» — bestehende Route, bestehende ID.
const openProperty = useCallback(() => {
if (!objectId) return
navigate(ROUTES.SUPPLY.PROPERTIES, { state: { propertyId: objectId } })
}, [navigate, objectId])
// Der ganze Verlauf gehört ins Anfragencenter, nicht in den Drawer.
const openCorrespondence = useCallback(() => {
navigate(ANFRAGENCENTER_ROUTE, { state: { objectId } })
}, [navigate, objectId])
const firstMessage = item?.messageThread?.slice(0, 1) ?? []
return ( return (
<Drawer <Drawer
anchor="right" anchor="right"
open={!!selectedId} open={!!selectedId}
onClose={close} onClose={close}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560, lg: 640 }, display: 'flex', flexDirection: 'column' } } }} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 520, lg: 600 }, display: 'flex', flexDirection: 'column' } } }}
> >
{/* Kopfbereich */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -64,7 +80,7 @@ export function WorkItemDetailDrawer() {
{agent ? `${agent.name} · ${agent.role}` : 'Vorgang'} {agent ? `${agent.name} · ${agent.role}` : 'Vorgang'}
</Typography> </Typography>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, lineHeight: 1.35 }}> <Typography component="h2" sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, lineHeight: 1.35 }}>
{item?.title ?? 'Vorgang wird geladen'} {item ? (item.objectLabel ? `${item.title} ${item.objectLabel}` : item.title) : 'Vorgang wird geladen'}
</Typography> </Typography>
</Box> </Box>
<IconButton size="small" onClick={close} aria-label="Detailansicht schliessen"> <IconButton size="small" onClick={close} aria-label="Detailansicht schliessen">
@@ -72,32 +88,17 @@ export function WorkItemDetailDrawer() {
</IconButton> </IconButton>
</Box> </Box>
{/* Inhalt */}
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}> <Box sx={{ flex: 1, overflowY: 'auto', bgcolor: DS_BG.page }}>
{isLoading && <PanelLoadingState />} {isLoading && <PanelLoadingState />}
{item && ( {item && (
<Box sx={{ p: 2, display: 'grid', gap: 2.5 }}> <Box sx={{ p: 2, display: 'grid', gap: 2.5 }}>
{/* Metazeile */} <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
<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)} {formatTeamDateTime(item.completedAt ?? item.createdAt)}
</Typography> </Typography>
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.summary}</Typography> <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) && ( {item.requiresDecision && (item.escalationReason || item.decisionQuestion) && (
<Box <Box
sx={{ sx={{
@@ -110,7 +111,7 @@ export function WorkItemDetailDrawer() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<HelpCircle size={15} color={DS_TEXT.warning} aria-hidden /> <HelpCircle size={15} color={DS_TEXT.warning} aria-hidden />
<Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', color: DS_TEXT.warningDark }}> <Typography sx={{ fontWeight: 700, fontSize: '0.8125rem', color: DS_TEXT.warningDark }}>
Warum ist ein Mensch erforderlich? Hierzu brauche ich Ihre Entscheidung
</Typography> </Typography>
</Box> </Box>
{item.escalationReason && ( {item.escalationReason && (
@@ -133,30 +134,25 @@ export function WorkItemDetailDrawer() {
</Box> </Box>
)} )}
{item.messageThread && item.messageThread.length > 0 && ( {/* Nur die auslösende Nachricht — der Rest liegt im Anfragencenter. */}
{firstMessage.length > 0 && (
<Box> <Box>
<DetailSectionTitle>Nachrichtenverlauf</DetailSectionTitle> <DetailSectionTitle>Auslösende Nachricht</DetailSectionTitle>
<MessageThread messages={item.messageThread} /> <MessageThread messages={firstMessage} />
</Box> <Button
)} size="small"
endIcon={<ArrowUpRight size={14} />}
{item.inputs.length > 0 && ( onClick={openCorrespondence}
<Box> sx={{ textTransform: 'none', fontWeight: 600, mt: 0.5, px: 0 }}
<DetailSectionTitle>Eingabedaten</DetailSectionTitle> >
<DetailFieldList fields={item.inputs} /> Gesamte Korrespondenz im Anfragencenter anzeigen
</Box> </Button>
)}
{item.processingSteps.length > 0 && (
<Box>
<DetailSectionTitle>Verarbeitungsschritte</DetailSectionTitle>
<ProcessingStepList steps={item.processingSteps} />
</Box> </Box>
)} )}
{item.sourceReferences.length > 0 && ( {item.sourceReferences.length > 0 && (
<Box ref={sourcesRef} sx={{ scrollMarginTop: 8 }}> <Box ref={sourcesRef} sx={{ scrollMarginTop: 8 }}>
<DetailSectionTitle>Fundstellen</DetailSectionTitle> <DetailSectionTitle>Quellen</DetailSectionTitle>
<SourceReferenceList references={item.sourceReferences} /> <SourceReferenceList references={item.sourceReferences} />
</Box> </Box>
)} )}
@@ -167,39 +163,16 @@ export function WorkItemDetailDrawer() {
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography> <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
</Box> </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>
)} )}
</Box> </Box>
{item && item.availableActions.length > 0 && ( {item && (
<WorkItemActions item={item} onOpenSource={scrollToSources} /> <WorkItemActions
item={item}
onShowSources={scrollToSources}
onOpenProperty={openProperty}
/>
)} )}
</Drawer> </Drawer>
) )
+7 -48
View File
@@ -1,17 +1,13 @@
import { Box, Button, InputAdornment, MenuItem, TextField } from '@mui/material' import { Box, Button, InputAdornment, MenuItem, TextField } from '@mui/material'
import { RotateCcw, Search } from 'lucide-react' import { RotateCcw, Search } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent' import type { TeamAgent } from '../../domain/teamAgent'
import { AgentChannelType } from '../../domain/teamAgent' import { AgentDomainArea, AgentWorkItemStatus } from '../../domain/agentWorkItem'
import { AgentDomainArea, AgentWorkItemKind, AgentWorkItemPriority, AgentWorkItemStatus } from '../../domain/agentWorkItem'
import { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters' import { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters'
import { useTeamStore } from '../../stores/teamStore' import { useTeamStore } from '../../stores/teamStore'
import { import {
AGENT_PERIOD_LABELS, AGENT_PERIOD_LABELS,
AGENT_DOMAIN_AREA_LABELS, AGENT_DOMAIN_AREA_LABELS,
AGENT_WORK_ITEM_KIND_LABELS,
AGENT_PRIORITY_LABELS,
AGENT_WORK_ITEM_STATUS_LABELS, AGENT_WORK_ITEM_STATUS_LABELS,
AGENT_CHANNEL_LABELS,
} from '../../lib/constants' } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
@@ -76,9 +72,6 @@ export function WorkItemFilterBar({ agents }: Props) {
const period = useTeamStore(s => s.historyPeriod) const period = useTeamStore(s => s.historyPeriod)
const agentId = useTeamStore(s => s.historyAgentId) const agentId = useTeamStore(s => s.historyAgentId)
const area = useTeamStore(s => s.historyArea) const area = useTeamStore(s => s.historyArea)
const kind = useTeamStore(s => s.historyKind)
const priority = useTeamStore(s => s.historyPriority)
const channel = useTeamStore(s => s.historyChannel)
const status = useTeamStore(s => s.historyStatus) const status = useTeamStore(s => s.historyStatus)
const search = useTeamStore(s => s.historySearch) const search = useTeamStore(s => s.historySearch)
const sort = useTeamStore(s => s.historySort) const sort = useTeamStore(s => s.historySort)
@@ -86,9 +79,6 @@ export function WorkItemFilterBar({ agents }: Props) {
const setPeriod = useTeamStore(s => s.setHistoryPeriod) const setPeriod = useTeamStore(s => s.setHistoryPeriod)
const setAgentId = useTeamStore(s => s.setHistoryAgentId) const setAgentId = useTeamStore(s => s.setHistoryAgentId)
const setArea = useTeamStore(s => s.setHistoryArea) const setArea = useTeamStore(s => s.setHistoryArea)
const setKind = useTeamStore(s => s.setHistoryKind)
const setPriority = useTeamStore(s => s.setHistoryPriority)
const setChannel = useTeamStore(s => s.setHistoryChannel)
const setStatus = useTeamStore(s => s.setHistoryStatus) const setStatus = useTeamStore(s => s.setHistoryStatus)
const setSearch = useTeamStore(s => s.setHistorySearch) const setSearch = useTeamStore(s => s.setHistorySearch)
const setSort = useTeamStore(s => s.setHistorySort) const setSort = useTeamStore(s => s.setHistorySort)
@@ -98,9 +88,6 @@ export function WorkItemFilterBar({ agents }: Props) {
period !== AgentPeriod.ALL || period !== AgentPeriod.ALL ||
agentId !== 'ALL' || agentId !== 'ALL' ||
area !== 'ALL' || area !== 'ALL' ||
kind !== 'ALL' ||
priority !== 'ALL' ||
channel !== 'ALL' ||
status !== 'ALL' || status !== 'ALL' ||
search.trim() !== '' || search.trim() !== '' ||
sort !== AgentWorkItemSort.NEWEST sort !== AgentWorkItemSort.NEWEST
@@ -110,22 +97,21 @@ export function WorkItemFilterBar({ agents }: Props) {
return ( return (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'grid',
gap: 1.25, gap: 1.25,
flexWrap: 'wrap',
alignItems: 'center',
px: 3, px: 3,
py: 1.5, py: 1.5,
borderBottom: `1px solid ${DS_BORDER.default}`, borderBottom: `1px solid ${DS_BORDER.default}`,
bgcolor: DS_BG.page, bgcolor: DS_BG.page,
}} }}
> >
{/* Zeile 1: nur die Suche — sie ist der häufigste Einstieg. */}
<TextField <TextField
size="small" size="small"
placeholder="Vorgänge durchsuchen" placeholder="Vorgänge durchsuchen"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }} sx={{ maxWidth: 420, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }}
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( startAdornment: (
@@ -138,6 +124,8 @@ export function WorkItemFilterBar({ agents }: Props) {
}} }}
/> />
{/* Zeile 2: die verbleibenden Filter, auf dem Desktop in einer Zeile. */}
<Box sx={{ display: 'flex', gap: 1.25, flexWrap: 'wrap', alignItems: 'center' }}>
<FilterSelect <FilterSelect
label="Zeitraum" label="Zeitraum"
value={period === AgentPeriod.ALL ? 'ALL' : period} value={period === AgentPeriod.ALL ? 'ALL' : period}
@@ -167,36 +155,6 @@ export function WorkItemFilterBar({ agents }: Props) {
onChange={setArea} onChange={setArea}
/> />
<FilterSelect
label="Vorgangstyp"
value={kind}
options={Object.values(AgentWorkItemKind)}
labels={AGENT_WORK_ITEM_KIND_LABELS}
allLabel="Alle Vorgangstypen"
onChange={setKind}
width={186}
/>
<FilterSelect
label="Priorität"
value={priority}
options={Object.values(AgentWorkItemPriority)}
labels={AGENT_PRIORITY_LABELS}
allLabel="Alle Prioritäten"
onChange={setPriority}
width={150}
/>
<FilterSelect
label="Kanal"
value={channel}
options={Object.values(AgentChannelType)}
labels={AGENT_CHANNEL_LABELS}
allLabel="Alle Kanäle"
onChange={setChannel}
width={175}
/>
<FilterSelect <FilterSelect
label="Status" label="Status"
value={status} value={status}
@@ -231,5 +189,6 @@ export function WorkItemFilterBar({ agents }: Props) {
</Button> </Button>
)} )}
</Box> </Box>
</Box>
) )
} }
@@ -63,25 +63,27 @@ function renderCard(
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
describe('WorkItemCard — Inhalt', () => { describe('WorkItemCard — Inhalt', () => {
it('rendert Titel, Zusammenfassung, Mitarbeitername und Rollenbezeichnung', () => { it('rendert Titel mit Liegenschaft, Zusammenfassung, Mitarbeitername und Rollenbezeichnung', () => {
const item = workItem(COMPLETED_ID) const item = workItem(COMPLETED_ID)
const agent = agentOf(item) const agent = agentOf(item)
renderCard(item) renderCard(item)
const card = screen.getByRole('button') const card = screen.getByRole('button')
expect(within(card).getByText(item.title)).toBeInTheDocument() // Titelformat: «Kurze Problemstellung Name der Immobilie»
const expectedTitle = item.objectLabel ? `${item.title} ${item.objectLabel}` : item.title
expect(within(card).getByText(expectedTitle)).toBeInTheDocument()
expect(within(card).getByText(item.summary)).toBeInTheDocument() expect(within(card).getByText(item.summary)).toBeInTheDocument()
expect(within(card).getByText(agent.name)).toBeInTheDocument() expect(within(card).getByText(agent.name)).toBeInTheDocument()
expect(within(card).getByText(agent.role)).toBeInTheDocument() expect(within(card).getByText(agent.role)).toBeInTheDocument()
}) })
it('zeigt bei einem Vorgang mit Objektbezug die Objekt-ID', () => { it('zeigt die Objekt-ID nicht mehr — die Liegenschaft steht im Titel', () => {
const item = workItem(COMPLETED_ID) const item = workItem(COMPLETED_ID)
const objectId = required(item.objectId, `Vorgang «${item.id}» hat keine Objekt-ID`) const objectId = required(item.objectId, `Vorgang «${item.id}» hat keine Objekt-ID`)
renderCard(item) renderCard(item)
const card = screen.getByRole('button') const card = screen.getByRole('button')
expect(within(card).getByText((content) => content.includes(objectId))).toBeInTheDocument() expect(within(card).queryByText((content) => content.includes(objectId))).toBeNull()
}) })
it('zeigt ohne Objektbezug keine Objekt-ID', () => { it('zeigt ohne Objektbezug keine Objekt-ID', () => {
@@ -95,14 +97,14 @@ describe('WorkItemCard — Inhalt', () => {
}) })
describe('WorkItemCard — Grund der Rückfrage', () => { describe('WorkItemCard — Grund der Rückfrage', () => {
it('zeigt bei einem pendenten Vorgang den escalationReason', () => { it('zeigt den Rückfragegrund nicht mehr auf der Karte — er steht im Detail', () => {
const item = workItem(PENDING_ID) const item = workItem(PENDING_ID)
const reason = required(item.escalationReason, `Vorgang «${item.id}» hat keinen Rückfragegrund`) const reason = required(item.escalationReason, `Vorgang «${item.id}» hat keinen Rückfragegrund`)
expect(item.requiresDecision).toBe(true) expect(item.requiresDecision).toBe(true)
renderCard(item) renderCard(item)
const card = screen.getByRole('button') const card = screen.getByRole('button')
expect(within(card).getByText(reason)).toBeInTheDocument() expect(within(card).queryByText(reason)).toBeNull()
}) })
it('zeigt bei einem erledigten Vorgang keinen escalationReason', () => { it('zeigt bei einem erledigten Vorgang keinen escalationReason', () => {
+9 -1
View File
@@ -37,7 +37,13 @@ export { AgentPreviewPopover } from './AgentPreviewPopover'
export { ConnectionSummaryList } from './ConnectionSummaryList' export { ConnectionSummaryList } from './ConnectionSummaryList'
// Personalverwaltung // Personalverwaltung
export { AgentDossier, DOSSIER_TABS, DEFAULT_DOSSIER_SEGMENT, isDossierSegment } from './AgentDossier' export {
AgentDossier,
DOSSIER_TABS,
DEFAULT_DOSSIER_SEGMENT,
isDossierSegment,
resolveDossierSegment,
} from './AgentDossier'
export type { DossierSegment } from './AgentDossier' export type { DossierSegment } from './AgentDossier'
export { AgentDescriptionTab } from './AgentDescriptionTab' export { AgentDescriptionTab } from './AgentDescriptionTab'
export { RotatableAgentWheel } from './RotatableAgentWheel' export { RotatableAgentWheel } from './RotatableAgentWheel'
@@ -45,6 +51,8 @@ export { AgentListPanel } from './AgentListPanel'
export { AgentDossierHeader } from './AgentDossierHeader' export { AgentDossierHeader } from './AgentDossierHeader'
export { AgentInfoBox } from './AgentInfoBox' export { AgentInfoBox } from './AgentInfoBox'
export { AgentTasksTab } from './AgentTasksTab' export { AgentTasksTab } from './AgentTasksTab'
export { AgentMetricsTab } from './AgentMetricsTab'
export { AgentConnectionsTab } from './AgentConnectionsTab'
export { AgentChannelsTab } from './AgentChannelsTab' export { AgentChannelsTab } from './AgentChannelsTab'
export { AgentSystemsTab } from './AgentSystemsTab' export { AgentSystemsTab } from './AgentSystemsTab'
export { AgentSettingsTab } from './AgentSettingsTab' export { AgentSettingsTab } from './AgentSettingsTab'
-1
View File
@@ -72,7 +72,6 @@ export default function Bearbeitungsverlauf() {
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader <TeamPageHeader
title="Bearbeitungsverlauf" title="Bearbeitungsverlauf"
description="Was die digitalen Mitarbeiter selbständig erledigt haben — und wo sie eine Entscheidung der Bewirtschaftung brauchen."
tabs={ tabs={
<Tabs <Tabs
value={activeTab} value={activeTab}
+63 -22
View File
@@ -1,19 +1,53 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { AgentConnectionCategory } from '../../domain/agentConnection'
import { DS_TEXT } from '../../lib/ds'
/** Vier Karten pro Zeile auf grossen Schirmen, zwei auf Tablet, eine auf Mobil. */
const GRID_COLUMNS = {
xs: '1fr',
md: 'repeat(2, minmax(0, 1fr))',
xl: 'repeat(4, minmax(0, 1fr))',
} as const
/**
* Kanäle zuerst, Systeme danach. Kanäle sind das, worüber Aufträge hereinkommen
* und Ergebnisse hinausgehen; Systeme das, woraus gelesen und wohin geschrieben
* wird. Die Reihenfolge folgt dem Arbeitsablauf.
*/
const SECTIONS = [
{
title: 'Kanäle',
categories: [
AgentConnectionCategory.EMAIL_M365,
AgentConnectionCategory.WHATSAPP,
AgentConnectionCategory.TEAMS,
AgentConnectionCategory.CALENDAR,
AgentConnectionCategory.PHONE_VOICE,
] as string[],
},
{
title: 'Systeme',
categories: [
AgentConnectionCategory.DOCUMENT_STORE,
AgentConnectionCategory.ERP,
AgentConnectionCategory.CRM,
AgentConnectionCategory.PUBLIC_SOURCES,
] as string[],
},
]
import { import {
TeamPageHeader, TeamPageHeader,
ConnectionCard, ConnectionCard,
ConnectionWizard, ConnectionWizard,
} from '../../components/team' } from '../../components/team'
import { CardSkeleton, ConfirmDialog, ErrorState } from '../../components/ui' import { CardSkeleton, ConfirmDialog, ErrorState } from '../../components/ui'
import { AgentConnectionStatus } from '../../domain/teamAgent'
import { useTeamAgents } from '../../hooks/useTeamAgents' import { useTeamAgents } from '../../hooks/useTeamAgents'
import { import {
useAgentConnections, useAgentConnections,
useDisconnectAgentConnection, useDisconnectAgentConnection,
useTestAgentConnection, useTestAgentConnection,
} from '../../hooks/useAgentConnections' } from '../../hooks/useAgentConnections'
import { DS_TEXT } from '../../lib/ds'
/** /**
* Subreiter «Kanäle & Systeme» (§13). * Subreiter «Kanäle & Systeme» (§13).
@@ -42,35 +76,38 @@ export default function KanaeleSysteme() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader <TeamPageHeader title="Kanäle & Systeme" />
title="Kanäle & Systeme"
description="Alle zentralen Verbindungen der Organisation. Die digitalen Mitarbeiter arbeiten in den Werkzeugen, die Sie ohnehin einsetzen."
/>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}> <Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{isError ? ( {isError ? (
<ErrorState message="Die Verbindungen konnten nicht geladen werden." onRetry={() => refetch()} /> <ErrorState message="Die Verbindungen konnten nicht geladen werden." onRetry={() => refetch()} />
) : ( ) : (
<> <>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 2 }}> {isLoading ? (
{connections.filter(c => c.status === AgentConnectionStatus.CONNECTED).length} von {connections.length}{' '} <Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: GRID_COLUMNS }}>
Verbindungen sind eingerichtet. {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => <CardSkeleton key={i} />)}
</Typography> </Box>
) : (
<Box SECTIONS.map((section) => {
const items = connections.filter(c => section.categories.includes(c.category))
if (items.length === 0) return null
return (
<Box component="section" key={section.title} sx={{ mb: 4 }}>
<Typography
component="h2"
sx={{ sx={{
display: 'grid', fontWeight: 700,
gap: 1.5, fontSize: '0.75rem',
gridTemplateColumns: { letterSpacing: '0.04em',
xs: '1fr', textTransform: 'uppercase',
md: 'repeat(2, minmax(0, 1fr))', color: DS_TEXT.muted,
xl: 'repeat(4, minmax(0, 1fr))', mb: 1.5,
},
}} }}
> >
{isLoading {section.title}
? [0, 1, 2, 3, 4, 5, 6, 7].map((i) => <CardSkeleton key={i} />) </Typography>
: connections.map((connection) => ( <Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: GRID_COLUMNS }}>
{items.map((connection) => (
<ConnectionCard <ConnectionCard
key={connection.id} key={connection.id}
connection={connection} connection={connection}
@@ -81,6 +118,10 @@ export default function KanaeleSysteme() {
/> />
))} ))}
</Box> </Box>
</Box>
)
})
)}
</> </>
)} )}
</Box> </Box>
+2 -3
View File
@@ -7,8 +7,7 @@ import {
AgentListPanel, AgentListPanel,
AgentDossier, AgentDossier,
RotatableAgentWheel, RotatableAgentWheel,
DEFAULT_DOSSIER_SEGMENT, resolveDossierSegment,
isDossierSegment,
} from '../../components/team' } from '../../components/team'
import type { DossierSegment } from '../../components/team' import type { DossierSegment } from '../../components/team'
import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui' import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui'
@@ -40,7 +39,7 @@ export default function Personalverwaltung() {
const view: PersonnelView = isView(searchParams.get(VIEW_PARAM)) const view: PersonnelView = isView(searchParams.get(VIEW_PARAM))
? (searchParams.get(VIEW_PARAM) as PersonnelView) ? (searchParams.get(VIEW_PARAM) as PersonnelView)
: VIEW.DOSSIER : VIEW.DOSSIER
const activeSegment: DossierSegment = isDossierSegment(tab) ? tab : DEFAULT_DOSSIER_SEGMENT const activeSegment: DossierSegment = resolveDossierSegment(tab)
const selectedAgent = useMemo(() => agents.find(a => a.id === agentId) ?? null, [agents, agentId]) const selectedAgent = useMemo(() => agents.find(a => a.id === agentId) ?? null, [agents, agentId])
// Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres // Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres
+1
View File
@@ -35,6 +35,7 @@ export default function Teamuebersicht() {
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<TeamPageHeader title="Teamübersicht" /> <TeamPageHeader title="Teamübersicht" />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 5 }}> <Box sx={{ flex: 1, overflowY: 'auto', px: 3, pb: 5 }}>
{isError ? ( {isError ? (
<ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} /> <ErrorState message="Das digitale Team konnte nicht geladen werden." onRetry={() => refetch()} />