Files
property-match/src/components/team/AgentDossierHeader.tsx
T
Benjamin Sutter 177641386a 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>
2026-08-04 13:30:32 +02:00

183 lines
6.8 KiB
TypeScript

import { useState } from 'react'
import { Box, FormControlLabel, Switch, Typography } from '@mui/material'
import { AlertTriangle, Mail } from 'lucide-react'
import type { TeamAgent } from '../../domain/teamAgent'
import { AgentStatus } from '../../domain/teamAgent'
import { AgentAvatar } from './AgentAvatar'
import { AgentStatusBadge } from './AgentBadges'
import { ConfirmDialog } from '../ui'
import { useSetAgentActive } from '../../hooks/useTeamAgents'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { formatTeamDateTime } from '../../lib/teamClock'
interface Props {
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 }) {
return (
<Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{value}
</Typography>
</Box>
)
}
/**
* Kopfbereich des Personalblatts (§7.3).
*
* Das Pausieren läuft über einen Bestätigungsdialog mit Hinweis auf die
* betroffenen Aufgaben — ein versehentlich pausierter Fristen-Wächter würde
* echte Termine verpassen (§7.5, §17.3).
*/
export function AgentDossierHeader({ agent }: Props) {
const [confirmPause, setConfirmPause] = useState(false)
const setActive = useSetAgentActive()
const isActive = agent.status === AgentStatus.ACTIVE
const enabledTasks = agent.tasks.filter(t => t.enabled).length
const handleToggle = (next: boolean) => {
if (next) setActive.mutate({ id: agent.id, active: true })
else setConfirmPause(true)
}
return (
<Box sx={{ px: 3, pt: 2.5, pb: 2, bgcolor: DS_BG.surface, borderBottom: `1px solid ${DS_BORDER.default}` }}>
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<AgentAvatar agent={agent} size="large" status={agent.status} tone={agent.avatarTone} showStatus />
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.25rem', color: DS_TEXT.primary, lineHeight: 1.25 }}>
{agent.name}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{agent.role}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Mail size={13} color={DS_TEXT.muted} aria-hidden />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>{agent.email}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mt: 1 }}>
<AgentStatusBadge status={agent.status} />
</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>
<FormControlLabel
control={
<Switch
checked={isActive}
disabled={setActive.isPending}
onChange={(e) => handleToggle(e.target.checked)}
slotProps={{ input: { 'aria-label': `${agent.name} aktiv` } }}
/>
}
label="Agent aktiv"
sx={{ mr: 0, '& .MuiFormControlLabel-label': { fontWeight: 600, fontSize: '0.875rem' } }}
/>
</Box>
{/* Stammdaten */}
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
mt: 2,
pt: 2,
borderTop: `1px solid ${DS_BORDER.muted}`,
}}
>
<MetaField label="Personalnummer" value={agent.personnelNumber} />
<MetaField label="Abteilung" value={agent.department} />
<MetaField
label="Letzter Lauf"
value={agent.lastRun ? formatTeamDateTime(agent.lastRun) : 'Noch nicht gelaufen'}
/>
</Box>
{/* Warnung im Personalblatt, solange pausiert (§7.5) */}
{!isActive && (
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'flex-start',
mt: 2,
p: 1.5,
borderRadius: 2,
bgcolor: DS_SURFACE.warning.bg,
border: `1px solid ${DS_SURFACE.warning.border}`,
}}
>
<AlertTriangle size={16} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark }}>
{agent.name} ist pausiert. Bis zur Reaktivierung werden keine Aufgaben ausgeführt und es
entstehen keine neuen Vorgänge.
</Typography>
</Box>
)}
<ConfirmDialog
open={confirmPause}
destructive
title={`${agent.name} pausieren?`}
message={`${enabledTasks} aktive Aufgaben werden ausgesetzt, bis Sie ${agent.name} wieder aktivieren. Laufende Fristen und Anfragen werden in dieser Zeit nicht bearbeitet. Der Vorgang wird protokolliert.`}
confirmLabel="Pausieren"
onConfirm={() => {
setActive.mutate({ id: agent.id, active: false })
setConfirmPause(false)
}}
onCancel={() => setConfirmPause(false)}
/>
</Box>
)
}