From 177641386ae3e6217e0f7c4f2316e49d1ba4dd10 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Tue, 4 Aug 2026 13:30:32 +0200 Subject: [PATCH] =?UTF-8?q?refactor(team):=20zweite=20=C3=9Cberarbeitungsr?= =?UTF-8?q?unde=20=E2=80=94=20Oberfl=C3=A4che=20bereinigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/layout/AppShellTopBar.tsx | 45 ++-- src/components/team/AgentConnectionsTab.tsx | 54 ++++ src/components/team/AgentDescriptionTab.tsx | 156 +++-------- src/components/team/AgentDossier.tsx | 34 ++- src/components/team/AgentDossierHeader.tsx | 46 +++- src/components/team/AgentMetricsTab.tsx | 71 +++++ src/components/team/AgentProtocolTab.tsx | 11 +- src/components/team/AgentTasksTab.tsx | 73 ++--- src/components/team/ConnectionCard.tsx | 66 ++--- src/components/team/RotatableAgentWheel.tsx | 122 +++++---- src/components/team/TeamOverviewRing.tsx | 20 +- src/components/team/TeamPageHeader.tsx | 8 - src/components/team/WorkItemActions.tsx | 254 ++++++------------ src/components/team/WorkItemCard.tsx | 153 +++-------- src/components/team/WorkItemDetailDrawer.tsx | 141 ++++------ src/components/team/WorkItemFilterBar.tsx | 55 +--- .../team/__tests__/WorkItemCard.test.tsx | 14 +- src/components/team/index.ts | 10 +- src/pages/supply/Bearbeitungsverlauf.tsx | 1 - src/pages/supply/KanaeleSysteme.tsx | 111 +++++--- src/pages/supply/Personalverwaltung.tsx | 5 +- src/pages/supply/Teamuebersicht.tsx | 1 + 22 files changed, 667 insertions(+), 784 deletions(-) create mode 100644 src/components/team/AgentConnectionsTab.tsx create mode 100644 src/components/team/AgentMetricsTab.tsx diff --git a/src/components/layout/AppShellTopBar.tsx b/src/components/layout/AppShellTopBar.tsx index 935ced8..f93f33a 100644 --- a/src/components/layout/AppShellTopBar.tsx +++ b/src/components/layout/AppShellTopBar.tsx @@ -27,6 +27,11 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top const pageName = getPageNameFromPath(pathname) 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 ( )} - - - {pageName} - + {showBreadcrumb && ( + <> + + + {pageName} + + + )} {/* Right side */} diff --git a/src/components/team/AgentConnectionsTab.tsx b/src/components/team/AgentConnectionsTab.tsx new file mode 100644 index 0000000..4ff7bf2 --- /dev/null +++ b/src/components/team/AgentConnectionsTab.tsx @@ -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 ( + + + + Kanäle + + + + + + + Systeme + + + + + ) +} diff --git a/src/components/team/AgentDescriptionTab.tsx b/src/components/team/AgentDescriptionTab.tsx index ac3b853..32f89ea 100644 --- a/src/components/team/AgentDescriptionTab.tsx +++ b/src/components/team/AgentDescriptionTab.tsx @@ -1,151 +1,59 @@ /** * Property On — Reiter «Agentenbeschrieb» im Personaldossier. * - * Der Einstieg ins Dossier: Er beantwortet in wenigen Zeilen, wofür ein - * digitaler Mitarbeiter zuständig ist, womit er arbeitet, was dabei herauskommt - * und wie viel davon zuletzt angefallen ist. Sämtliche Angaben stammen - * unverändert aus dem Personaldossier — hier wird nichts formuliert, was nicht - * in den Daten steht. + * Bewusst nur ein kurzer Absatz. Die frühere Dreiteilung aus Input, Kernablauf + * und Output war eine Prozessnotation — sie beschrieb, wie der Mitarbeitende + * arbeitet, nicht wofür man ihn hat. Die Zuständigkeiten stehen jetzt im + * Kopfbereich, die Kennzahlen im eigenen Reiter. * - * Zwei bewusste Entscheide: - * - * 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. + * 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. */ -import { memo, useMemo } from 'react' +import { useMemo } from 'react' import { Box, Typography } from '@mui/material' -import type { AgentMetric, TeamAgent } from '../../domain/teamAgent' -import { AgentInfoBox } from './AgentInfoBox' -import { DS_BORDER, DS_TEXT } from '../../lib/ds' - -/** Sichtbare Zuständigkeiten; alles Weitere wird nur noch gezählt. */ -const VISIBLE_RESPONSIBILITIES = 3 +import type { TeamAgent } from '../../domain/teamAgent' +import { DS_TEXT } from '../../lib/ds' /** * 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 - * Zweck den Kurzbeschrieb weitgehend ab, ist der Kurzbeschrieb eine Wiederholung. + * kaum Bedeutung («und», «bis», «der»), deshalb zählen nur längere. */ const MIN_WORD_LENGTH = 5 const REDUNDANCY_THRESHOLD = 0.6 function contentWords(text: string): Set { - const words = text - .toLowerCase() - .split(/[^a-zäöüéèàç]+/) - .filter(word => word.length >= MIN_WORD_LENGTH) - return new Set(words) + return new Set( + text.toLowerCase().split(/[^a-zäöüß]+/).filter(w => w.length >= MIN_WORD_LENGTH), + ) } -function saysTheSame(shortText: string, longText: string): boolean { - const own = contentWords(shortText) - if (own.size === 0) return true - const other = contentWords(longText) +function saysTheSame(a: string, b: string): boolean { + const left = contentWords(a) + if (left.size === 0) return false + const right = contentWords(b) let shared = 0 - for (const word of own) if (other.has(word)) shared += 1 - return shared / own.size >= REDUNDANCY_THRESHOLD + for (const word of left) if (right.has(word)) shared += 1 + return shared / left.size >= REDUNDANCY_THRESHOLD } -// ── Bausteine ───────────────────────────────────────────────────────────────── - -function SectionLabel({ children }: { children: string }) { - return ( - - {children} - - ) -} - -const MetricItem = memo(function MetricItem({ metric }: { metric: AgentMetric }) { - return ( - - - {metric.value} - - - {metric.label} - - - ) -}) - -const ResponsibilityItem = memo(function ResponsibilityItem({ text }: { text: string }) { - return ( - - {text} - - ) -}) - -// ── Reiter ──────────────────────────────────────────────────────────────────── - export function AgentDescriptionTab({ agent }: { agent: TeamAgent }) { - const showShortDescription = useMemo( - () => !saysTheSame(agent.shortDescription, agent.profile.purpose), - [agent.shortDescription, agent.profile.purpose], - ) - - const visibleResponsibilities = useMemo( - () => agent.responsibilities.slice(0, VISIBLE_RESPONSIBILITIES), - [agent.responsibilities], - ) - - const hiddenResponsibilities = agent.responsibilities.length - visibleResponsibilities.length + const sentences = useMemo(() => { + const first = agent.shortDescription.trim() + const second = agent.profile.purpose.trim() + return saysTheSame(first, second) ? [first] : [first, second] + }, [agent.shortDescription, agent.profile.purpose]) return ( - - {showShortDescription && ( - - {agent.shortDescription} + + {sentences.map((sentence) => ( + + {sentence} - )} - - {/* 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. */} - - - - - - Kennzahlen - - {agent.metrics.map((metric) => ( - - ))} - - - - - Zuständigkeiten - - {visibleResponsibilities.map((text) => ( - - ))} - - {hiddenResponsibilities > 0 && ( - - {hiddenResponsibilities} weitere von insgesamt {agent.responsibilities.length} Zuständigkeiten - - )} - + ))} ) } diff --git a/src/components/team/AgentDossier.tsx b/src/components/team/AgentDossier.tsx index cc68199..afe676b 100644 --- a/src/components/team/AgentDossier.tsx +++ b/src/components/team/AgentDossier.tsx @@ -19,8 +19,8 @@ import type { TeamAgent } from '../../domain/teamAgent' import { AgentDossierHeader } from './AgentDossierHeader' import { AgentDescriptionTab } from './AgentDescriptionTab' import { AgentTasksTab } from './AgentTasksTab' -import { AgentChannelsTab } from './AgentChannelsTab' -import { AgentSystemsTab } from './AgentSystemsTab' +import { AgentMetricsTab } from './AgentMetricsTab' +import { AgentConnectionsTab } from './AgentConnectionsTab' import { AgentSettingsTab } from './AgentSettingsTab' import { AgentProtocolTab } from './AgentProtocolTab' import { DS_BG, DS_BORDER } from '../../lib/ds' @@ -37,21 +37,39 @@ import { DS_BG, DS_BORDER } from '../../lib/ds' export const DOSSIER_TABS = [ { segment: 'beschrieb', label: 'Agentenbeschrieb' }, { segment: 'aufgaben', label: 'Aufgaben' }, - { segment: 'kanaele', label: 'Kanäle' }, - { segment: 'systeme', label: 'Systeme' }, - { segment: 'einstellungen', label: 'Einstellungen' }, + { segment: 'kennzahlen', label: 'Kennzahlen' }, + { segment: 'kanaele', label: 'Kanäle & Systeme' }, { 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 export type DossierSegment = typeof DOSSIER_TABS[number]['segment'] 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 = { systeme: 'kanaele' } + // eslint-disable-next-line react-refresh/only-export-components -- siehe DOSSIER_TABS export function isDossierSegment(value: string | undefined): value is DossierSegment { 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. */ const tabId = (segment: DossierSegment) => `agent-dossier-tab-${segment}` const panelId = (segment: DossierSegment) => `agent-dossier-panel-${segment}` @@ -108,10 +126,10 @@ export function AgentDossier({ agent, activeSegment, onSegmentChange }: Props) { > {activeSegment === 'beschrieb' && } {activeSegment === 'aufgaben' && } - {activeSegment === 'kanaele' && } - {activeSegment === 'systeme' && } - {activeSegment === 'einstellungen' && } + {activeSegment === 'kennzahlen' && } + {activeSegment === 'kanaele' && } {activeSegment === 'protokoll' && } + {activeSegment === 'einstellungen' && } ) diff --git a/src/components/team/AgentDossierHeader.tsx b/src/components/team/AgentDossierHeader.tsx index 63b6d3e..c8606a6 100644 --- a/src/components/team/AgentDossierHeader.tsx +++ b/src/components/team/AgentDossierHeader.tsx @@ -4,7 +4,7 @@ import { AlertTriangle, Mail } from 'lucide-react' import type { TeamAgent } from '../../domain/teamAgent' import { AgentStatus } from '../../domain/teamAgent' import { AgentAvatar } from './AgentAvatar' -import { AgentAutonomyBadge, AgentStatusBadge } from './AgentBadges' +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' @@ -14,6 +14,22 @@ 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 ( @@ -65,8 +81,33 @@ export function AgentDossierHeader({ agent }: Props) { - + + {/* Kurzer Beschrieb und Zuständigkeiten stehen jetzt oben statt tief im + Dossier — das ist die Frage, die man beim Öffnen zuerst hat. */} + + {agent.shortDescription} + + + {agent.responsibilities.length > 0 && ( + + + Zuständig für: + + {agent.responsibilities.slice(0, MAX_RESPONSIBILITIES).map((r, i) => ( + + {i > 0 && ·} + + {shortLabel(r)} + + + ))} + + )} - + + {metric.value} + + + {metric.label} + + {metric.hint && ( + + {metric.hint} + + )} + + ) +}) + +export function AgentMetricsTab({ agent }: { agent: TeamAgent }) { + if (agent.metrics.length === 0) { + return ( + + ) + } + + return ( + + {agent.metrics.map((metric) => ( + + ))} + + ) +} diff --git a/src/components/team/AgentProtocolTab.tsx b/src/components/team/AgentProtocolTab.tsx index fae15a8..24e38e1 100644 --- a/src/components/team/AgentProtocolTab.tsx +++ b/src/components/team/AgentProtocolTab.tsx @@ -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 type { TeamAgent } from '../../domain/teamAgent' 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 type { AgentProtocolFilters } from '../../provider/IAgentProtocolProvider' import { useAgentProtocol } from '../../hooks/useAgentProtocol' @@ -145,7 +145,6 @@ const ProtocolEntryRow = memo(function ProtocolEntryRow({ entry, expanded, isLas export function AgentProtocolTab({ agent }: { agent: TeamAgent }) { const [period, setPeriod] = useState(AgentPeriod.ALL) - const [eventType, setEventType] = useState('ALL') const [status, setStatus] = useState('ALL') const [search, setSearch] = useState('') const [onlyApprovals, setOnlyApprovals] = useState(false) @@ -154,11 +153,10 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) { const filters = useMemo(() => ({ agentId: agent.id, period, - eventType: eventType === 'ALL' ? undefined : eventType, status: status === 'ALL' ? undefined : status, onlyApprovals: onlyApprovals || 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 lastId = entries.length > 0 ? entries[entries.length - 1].id : null @@ -167,7 +165,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) { const resetFilters = useCallback(() => { setPeriod(AgentPeriod.ALL) - setEventType('ALL') setStatus('ALL') setSearch('') setOnlyApprovals(false) @@ -190,10 +187,6 @@ export function AgentProtocolTab({ agent }: { agent: TeamAgent }) { labels={AGENT_PERIOD_LABELS} allLabel={AGENT_PERIOD_LABELS.ALL} onChange={(v) => setPeriod(v === 'ALL' ? AgentPeriod.ALL : v)} /> - { - const out: string[] = [] - if (task.dependsOnChannel) { - out.push(`Kanal: ${AGENT_CHANNEL_LABELS[task.dependsOnChannel] ?? task.dependsOnChannel}`) - } - if (task.dependsOnSystem) { - out.push(`Systemzugang: ${AGENT_SYSTEM_LABELS[task.dependsOnSystem] ?? task.dependsOnSystem}`) - } - return out - }, [task.dependsOnChannel, task.dependsOnSystem]) - const handleChange = useCallback( (_event: unknown, checked: boolean) => onToggle(task.id, checked), [onToggle, task.id], @@ -51,9 +39,9 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) { component="li" sx={{ display: 'flex', - gap: 1.5, + gap: 1, alignItems: 'flex-start', - p: 1.75, + p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 2, 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) */} - + - + {task.title} - + {/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle + Wortlaut steht am Element. */} + {task.description} - - - - Zeitplan: {task.schedule} - - - - {dependencies.map((dependency) => ( - - - - Setzt voraus — {dependency} - - - ))} - {task.requiresApproval && ( - + ) : ( - + {draft.map((task) => ( ))} diff --git a/src/components/team/ConnectionCard.tsx b/src/components/team/ConnectionCard.tsx index c33579a..0851dd7 100644 --- a/src/components/team/ConnectionCard.tsx +++ b/src/components/team/ConnectionCard.tsx @@ -10,7 +10,7 @@ * 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 { Calendar, @@ -27,7 +27,6 @@ import type { LucideIcon } from 'lucide-react' import type { AgentConnection } from '../../domain/agentConnection' import { AgentConnectionCategory } from '../../domain/agentConnection' import { AgentConnectionStatus } from '../../domain/teamAgent' -import { AgentConnectionStatusBadge } from './AgentBadges' import { AgentAvatarGroup } from './AgentAvatarGroup' import { AGENT_CONNECTION_CATEGORY_LABELS } from '../../lib/constants' import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' @@ -45,28 +44,6 @@ const CATEGORY_ICON: Record = { [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 * Teamübersicht genutzt, damit beide Ansichten dasselbe Zeichen führen. @@ -114,7 +91,6 @@ export const ConnectionCard = memo(function ConnectionCard({ }: Props) { const isConnected = connection.status === AgentConnectionStatus.CONNECTED const isRoadmap = connection.status === AgentConnectionStatus.ROADMAP - const purpose = useMemo(() => firstSentence(connection.description), [connection.description]) const handleToggle = useCallback(() => { if (isConnected) onDisconnect(connection.id) @@ -160,31 +136,25 @@ export const ConnectionCard = memo(function ConnectionCard({ {connection.name} - + {/* Zustand als Text neben dem Regler — Farbe allein trägt keinen Status. */} + + + {isRoadmap ? 'Geplant' : isConnected ? 'Aktiv' : 'Inaktiv'} + + + - - {purpose} - - - - - - + void } -/** Grad Drehung je gezogenem Pixel. Ein voller Durchlauf braucht so gut 700 px. */ -const DRAG_DEGREES_PER_PX = 0.5 +/** Grad Drehung je gezogenem Pixel. Bewusst träge — ein Wisch soll nicht durchs Team jagen. */ +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. */ -const WHEEL_DIAMETER = 720 -const AVATAR_PX = 66 +const WHEEL_DIAMETER = 460 +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. * - * Der gedachte Vollkreis ragt nach oben aus dem Container heraus und wird von - * ihm abgeschnitten — sichtbar bleibt der untere Halbkreis. Deshalb wird nichts - * am Bild zugeschnitten, sondern allein per `overflow: hidden` beschnitten. + * Geometrie: `rotate(a) translateY(r)` schiebt ein Element bei a = 0 nach UNTEN. + * Ein Knoten mit Grundwinkel `i · step` steht also genau dann unten in der Mitte, + * 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 - * ein lokaler Drehwert mit; die Auswahl wechselt erst beim Einrasten. Sonst - * würde bei jeder Mausbewegung das gesamte Dossier darunter neu aufgebaut. + * Der gedachte Vollkreis ragt nach oben aus dem Container heraus und wird von + * ihm abgeschnitten — es wird nichts am Bild zugeschnitten, nur beschnitten. + * + * 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) { const step = agents.length > 0 ? 360 / agents.length : 360 const selectedIndex = Math.max(0, agents.findIndex(a => a.id === selectedId)) 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 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. - const baseDeg = 180 - selectedIndex * step + // Drehung, die den gewählten Mitarbeitenden nach unten in die Mitte bringt. + const baseDeg = -selectedIndex * step const rotation = baseDeg + dragDeg - const positions = useMemo( - () => agents.map((_, i) => i * step), - [agents, step], - ) + const angles = useMemo(() => agents.map((_, i) => i * step), [agents, step]) - const commitNearest = useCallback( - (currentRotation: number) => { - if (agents.length === 0) return - // Welcher Index steht dieser Drehung am nächsten? - const raw = (180 - currentRotation) / step - const index = ((Math.round(raw) % agents.length) + agents.length) % agents.length - const next = agents[index] - if (next && next.id !== selectedId) onSelect(next.id) - setDragDeg(0) + /** Wie viele Plätze eine Zugweite bedeutet — kurze Züge höchstens einen. */ + const stepsFor = useCallback( + (dx: number) => { + if (Math.abs(dx) < DRAG_THRESHOLD_PX) return 0 + const raw = -(dx * DRAG_DEGREES_PER_PX) / step + const rounded = Math.round(raw) + if (Math.abs(dx) <= SINGLE_STEP_LIMIT_PX) return clamp(rounded, -1, 1) + return rounded }, - [agents, step, selectedId, onSelect], + [step], ) const handlePointerDown = useCallback((e: React.PointerEvent) => { - dragging.current = { startX: e.clientX } + drag.current = { startX: e.clientX } setIsDragging(true) e.currentTarget.setPointerCapture(e.pointerId) }, []) const handlePointerMove = useCallback((e: React.PointerEvent) => { - if (!dragging.current) return - setDragDeg((e.clientX - dragging.current.startX) * DRAG_DEGREES_PER_PX) + if (!drag.current) return + setDragDeg((e.clientX - drag.current.startX) * DRAG_DEGREES_PER_PX) }, []) const handlePointerUp = useCallback( (e: React.PointerEvent) => { - if (!dragging.current) return - const offset = (e.clientX - dragging.current.startX) * DRAG_DEGREES_PER_PX - dragging.current = null + if (!drag.current) return + const dx = e.clientX - drag.current.startX + drag.current = null 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( @@ -93,13 +103,8 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { - e.preventDefault() - move(1) - } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { - e.preventDefault() - move(-1) - } + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); move(1) } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); move(-1) } }, [move], ) @@ -110,7 +115,7 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) { {agents.map((agent, index) => { - const angle = positions[index] + const angle = angles[index] const isActive = index === selectedIndex + const size = isActive ? AVATAR_PX + 12 : AVATAR_PX return ( {isActive && ( - + {agent.role} )} @@ -202,7 +208,7 @@ export function RotatableAgentWheel({ agents, selectedId, onSelect }: Props) { variant="caption" sx={{ position: 'absolute', left: 16, bottom: 8, color: DS_TEXT.muted }} > - Ziehen oder Pfeiltasten, um zu wechseln — {agents.length} Mitarbeitende + Ziehen zum Wechseln ) diff --git a/src/components/team/TeamOverviewRing.tsx b/src/components/team/TeamOverviewRing.tsx index c7d45df..f7ff430 100644 --- a/src/components/team/TeamOverviewRing.tsx +++ b/src/components/team/TeamOverviewRing.tsx @@ -15,6 +15,12 @@ interface Props { level: AgentLevel } +/** Verkleinerung nach Anzahl sichtbarer Ringe. */ +const RING_COUNT_SCALE: Record = { 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. */ function useContainerWidth(): [React.RefObject, number] { const ref = useRef(null) @@ -61,6 +67,15 @@ export function TeamOverviewRing({ level }: Props) { const scale = width / RING_REFERENCE_PX 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 map = new Map() for (const spec of RING_SPECS) { @@ -104,10 +119,11 @@ export function TeamOverviewRing({ level }: Props) { aria-label="Digitale Belegschaft" sx={{ position: 'relative', - width: '100%', - maxWidth: RING_REFERENCE_PX, + width: boxSize, aspectRatio: '1 / 1', mx: 'auto', + transition: 'width 0.35s ease', + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, }} > {RING_SPECS.map((spec, index) => ( diff --git a/src/components/team/TeamPageHeader.tsx b/src/components/team/TeamPageHeader.tsx index 8af99be..bfb78bc 100644 --- a/src/components/team/TeamPageHeader.tsx +++ b/src/components/team/TeamPageHeader.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react' 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' interface Props { @@ -57,12 +55,6 @@ export function TeamPageHeader({ title, description, actions, tabs }: Props) { )} - - - - {AGENT_DEMO_NOTICE} - - {actions && ( diff --git a/src/components/team/WorkItemActions.tsx b/src/components/team/WorkItemActions.tsx index ce866f7..39c32c2 100644 --- a/src/components/team/WorkItemActions.tsx +++ b/src/components/team/WorkItemActions.tsx @@ -1,53 +1,39 @@ import { useState } from 'react' import { Box, Button } from '@mui/material' -import { Check, CheckCheck, FileSearch, MessageSquareReply, Pencil, Scale, X } from 'lucide-react' -import type { AgentWorkItem, AgentEditableField } from '../../domain/agentWorkItem' -import { AgentWorkItemAction } from '../../domain/agentWorkItem' +import { Building2, Check, FileSearch, X } from 'lucide-react' +import type { AgentWorkItem } from '../../domain/agentWorkItem' import { ConfirmDialog } from '../ui' -import { RejectWorkItemDialog, EditWorkItemDialog, AnswerQueryDialog } from './WorkItemActionDialogs' -import { - useApproveWorkItem, - useRejectWorkItem, - useSaveWorkItemEdit, - useAnswerWorkItemQuery, - useMarkWorkItemDone, -} from '../../hooks/useAgentWorkItems' +import { RejectWorkItemDialog } from './WorkItemActionDialogs' +import { useApproveWorkItem, useRejectWorkItem } from '../../hooks/useAgentWorkItems' import { DS_BG, DS_BORDER } from '../../lib/ds' -type OpenDialog = 'none' | 'approve' | 'decide' | 'reject' | 'edit' | 'answer' - interface Props { item: AgentWorkItem - /** Springt zu den Fundstellen — «Quelle öffnen» ohne echtes Zielsystem. */ - onOpenSource: () => void + /** Springt im Drawer zum Abschnitt «Quellen» — öffnet keine neue Seite. */ + 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 - * sind irreversibel und dürfen nicht versehentlich ausgelöst werden (§17.3). - * 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. + * Pendent: Freigeben, Zurückweisen, Quellen anzeigen, Inserat anzeigen. + * Erledigt: nur noch der Weg zum Objekt — entschieden ist entschieden. + * + * Die Freigabe läuft über einen Bestätigungsdialog: sie ist irreversibel und + * darf nicht versehentlich ausgelöst werden. */ -export function WorkItemActions({ item, onOpenSource }: Props) { - const [dialog, setDialog] = useState('none') +export function WorkItemActions({ item, onShowSources, onOpenProperty }: Props) { + const [confirmApprove, setConfirmApprove] = useState(false) + const [confirmReject, setConfirmReject] = useState(false) const approve = useApproveWorkItem() const reject = useRejectWorkItem() - const saveEdit = useSaveWorkItemEdit() - const answer = useAnswerWorkItemQuery() - const markDone = useMarkWorkItemDone() + const busy = approve.isPending || reject.isPending - const busy = - approve.isPending || reject.isPending || saveEdit.isPending || answer.isPending || markDone.isPending - - const can = (action: string) => item.availableActions.includes(action as never) - const close = () => setDialog('none') - - const runApprove = () => { - approve.mutate(item.id, { onSuccess: close }) - } + const hasSources = item.sourceReferences.length > 0 + const hasProperty = !!item.objectId return ( <> @@ -62,158 +48,88 @@ export function WorkItemActions({ item, onOpenSource }: Props) { flexShrink: 0, }} > - {can(AgentWorkItemAction.ONE_CLICK_CONFIRM) && ( - - )} + {item.requiresDecision ? ( + <> + - {can(AgentWorkItemAction.APPROVE) && ( - - )} + - {can(AgentWorkItemAction.DECIDE) && ( - - )} + {hasSources && ( + + )} - {can(AgentWorkItemAction.ANSWER_QUERY) && ( - - )} - - {can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && ( - - )} - - {can(AgentWorkItemAction.REJECT) && ( - - )} - - {can(AgentWorkItemAction.MARK_DONE) && ( - - )} - - {can(AgentWorkItemAction.OPEN_SOURCE) && item.sourceReferences.length > 0 && ( - + {hasProperty && ( + + )} + + ) : ( + hasProperty && ( + + ) )} - - { + approve.mutate(item.id, { onSuccess: () => setConfirmApprove(false) }) + }} + onCancel={() => setConfirmApprove(false)} /> reject.mutate({ id: item.id, reason }, { onSuccess: close })} - /> - - - saveEdit.mutate({ id: item.id, fields }, { onSuccess: close }) + onCancel={() => setConfirmReject(false)} + onConfirm={(reason) => + reject.mutate({ id: item.id, reason }, { onSuccess: () => setConfirmReject(false) }) } /> - - answer.mutate({ id: item.id, answer: text }, { onSuccess: close })} - /> ) } diff --git a/src/components/team/WorkItemCard.tsx b/src/components/team/WorkItemCard.tsx index b120d9c..604013e 100644 --- a/src/components/team/WorkItemCard.tsx +++ b/src/components/team/WorkItemCard.tsx @@ -1,22 +1,10 @@ import { memo, useMemo } from 'react' -import type { ReactNode } from 'react' import { Box, Tooltip, Typography } from '@mui/material' -import { AlertTriangle, Building2, HelpCircle, Radio } from 'lucide-react' -import type { AgentWorkItem, AgentWorkItemPriority } from '../../domain/agentWorkItem' -import { - AgentWorkItemPriority as Priority, - AgentWorkItemStatus as WorkItemStatus, -} from '../../domain/agentWorkItem' +import type { AgentWorkItem } from '../../domain/agentWorkItem' import type { TeamAgent } from '../../domain/teamAgent' 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 { formatTeamDateTime, formatTeamRelative } from '../../lib/teamClock' +import { formatTeamDateTime } from '../../lib/teamClock' interface Props { item: AgentWorkItem @@ -27,66 +15,28 @@ interface Props { 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> = { - [Priority.CRITICAL]: DS_TEXT.error, - [Priority.HIGH]: DS_TEXT.warning, -} - -/** Dezentes Trennzeichen statt eingefärbter Flächen zwischen den Fussangaben. */ -function MetaDot() { - return ( - - · - - ) -} - -function MetaEntry({ icon, text }: { icon?: ReactNode; text: string }) { - return ( - - {icon} - - {text} - - - ) -} - /** * Ein Eintrag im Bearbeitungsverlauf. * - * `React.memo` ist hier nicht optional: die Liste rendert bis zu 14 Karten und - * der Drawer verändert bei jeder Auswahl den Zustand der Seite — ohne Memo - * würde jede Auswahl die gesamte Liste neu zeichnen (CLAUDE.md §10.2). + * Bewusst auf das Nötigste reduziert: wer, was, wann. Priorität, Status, + * Vorgangstyp und Kanal standen auf praktisch jeder Karte und trugen deshalb + * 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 - * dort, wo sie eine Abweichung meldet — Ausnahmepriorität, ein vom - * Erwartungswert abweichender Status und der ausgewählte Zustand. + * `React.memo` ist nicht optional: der Drawer verändert bei jeder Auswahl den + * Zustand der Seite, ohne Memo würde die ganze Liste neu zeichnen (CLAUDE.md §10.2). */ 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 priorityAccent = PRIORITY_ACCENT[item.priority] - // Auch ohne Objekt-ID anzeigen: Vorgänge ohne Objektbezug tragen im Label oft - // die entscheidende Einordnung («Zuordnung offen — Region Zug»). Hinge der - // Block allein an der ID, ginge sie verloren. - const objectText = useMemo( - () => [item.objectId, item.objectLabel].filter(Boolean).join(' · '), - [item.objectId, item.objectLabel], + // «Kurze Problemstellung – Name der Immobilie». Fehlt der Objektbezug, bleibt + // der Titel allein stehen statt mit einem leeren Gedankenstrich zu enden. + const title = useMemo( + () => (item.objectLabel ? `${item.title} – ${item.objectLabel}` : item.title), + [item.title, 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 ( {agent && ( - - - {agent.name} - - - {agent.role} - - - } - > + {/* Wrapper, weil MUI dem Kind eine Referenz anhängt — `AgentAvatar` ist memoisiert und nimmt selbst keine entgegen. */} @@ -137,7 +75,6 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, )} - {/* Kopfzeile: Mitarbeiter, Rolle, Zeitpunkt */} {agent?.name ?? 'Unbekannt'} @@ -150,54 +87,24 @@ export const WorkItemCard = memo(function WorkItemCard({ item, agent, selected, - - {item.title} + + {title} - + {item.summary} - - {/* 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 && ( - - - - {item.escalationReason} - - - )} - - {/* Fusszeile: Ausnahmen zuerst, danach Vorgangstyp, Objektbezug, Kanal */} - - {priorityAccent && ( - - - - {AGENT_PRIORITY_LABELS[item.priority] ?? item.priority} - - - )} - - {showStatus && } - - - - {objectText && ( - <> - - } text={objectText} /> - - )} - - - } text={channelLabel} /> - - - {formatTeamRelative(timestamp)} - - diff --git a/src/components/team/WorkItemDetailDrawer.tsx b/src/components/team/WorkItemDetailDrawer.tsx index 6ff6759..3ed79ee 100644 --- a/src/components/team/WorkItemDetailDrawer.tsx +++ b/src/components/team/WorkItemDetailDrawer.tsx @@ -1,30 +1,29 @@ import { useCallback, useRef } from 'react' -import { Box, Divider, Drawer, IconButton, Typography } from '@mui/material' -import { HelpCircle, X } from 'lucide-react' +import { Box, Button, Drawer, IconButton, Typography } from '@mui/material' +import { ArrowUpRight, HelpCircle, X } from 'lucide-react' +import { useNavigate } from 'react-router' import { useTeamStore } from '../../stores/teamStore' import { useAgentWorkItem } from '../../hooks/useAgentWorkItems' import { useTeamAgents } from '../../hooks/useTeamAgents' import { AgentAvatar } from './AgentAvatar' -import { AgentPriorityBadge, AgentWorkItemStatusBadge } from './AgentBadges' import { WorkItemActions } from './WorkItemActions' -import { - DetailFieldList, - DetailSectionTitle, - MessageThread, - ProcessingStepList, - SourceReferenceList, -} from './WorkItemDetailSections' +import { DetailFieldList, DetailSectionTitle, MessageThread, SourceReferenceList } from './WorkItemDetailSections' 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 { 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 - * nach jeder Entscheidung sofort wieder in ihr stehen — ein Seitenwechsel - * würde bei jedem Vorgang den Kontext zerstören. + * Bewusst schmal gehalten: Priorität, Status, Vorgangstyp, Verarbeitungsschritte + * und Aktionshistorie sind entfallen. Sie beschrieben die Maschine, nicht den + * 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() { const selectedId = useTeamStore(s => s.selectedWorkItemId) @@ -32,6 +31,7 @@ export function WorkItemDetailDrawer() { const { data: item, isLoading } = useAgentWorkItem(selectedId) const { data: agents = [] } = useTeamAgents() const sourcesRef = useRef(null) + const navigate = useNavigate() const agent = agents.find(a => a.id === item?.agentId) const close = useCallback(() => setSelectedId(null), [setSelectedId]) @@ -40,14 +40,30 @@ export function WorkItemDetailDrawer() { 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 ( - {/* Kopfbereich */} - {item?.title ?? 'Vorgang wird geladen'} + {item ? (item.objectLabel ? `${item.title} – ${item.objectLabel}` : item.title) : 'Vorgang wird geladen'} @@ -72,32 +88,17 @@ export function WorkItemDetailDrawer() { - {/* Inhalt */} {isLoading && } {item && ( - {/* Metazeile */} - - - - - {AGENT_WORK_ITEM_KIND_LABELS[item.kind] ?? item.kind} - - {item.objectId && ( - - · {item.objectId}{item.objectLabel ? ` · ${item.objectLabel}` : ''} - - )} - - {formatTeamDateTime(item.completedAt ?? item.createdAt)} - - + + {formatTeamDateTime(item.completedAt ?? item.createdAt)} + {item.summary} - {/* Rückfrage — bei pendenten Vorgängen die wichtigste Information */} {item.requiresDecision && (item.escalationReason || item.decisionQuestion) && ( - Warum ist ein Mensch erforderlich? + Hierzu brauche ich Ihre Entscheidung {item.escalationReason && ( @@ -133,30 +134,25 @@ export function WorkItemDetailDrawer() { )} - {item.messageThread && item.messageThread.length > 0 && ( + {/* Nur die auslösende Nachricht — der Rest liegt im Anfragencenter. */} + {firstMessage.length > 0 && ( - Nachrichtenverlauf - - - )} - - {item.inputs.length > 0 && ( - - Eingabedaten - - - )} - - {item.processingSteps.length > 0 && ( - - Verarbeitungsschritte - + Auslösende Nachricht + + )} {item.sourceReferences.length > 0 && ( - Fundstellen + Quellen )} @@ -167,39 +163,16 @@ export function WorkItemDetailDrawer() { {item.rejectionReason} )} - - {item.history.length > 0 && ( - - Aktionshistorie - - {item.history.map((entry) => ( - - - {formatTeamDateTime(entry.at)} - - - {entry.action} - - - {entry.actor} - - {entry.note && ( - <> - - {entry.note} - - )} - - ))} - - - )} )} - {item && item.availableActions.length > 0 && ( - + {item && ( + )} ) diff --git a/src/components/team/WorkItemFilterBar.tsx b/src/components/team/WorkItemFilterBar.tsx index 24484a8..1b44ee0 100644 --- a/src/components/team/WorkItemFilterBar.tsx +++ b/src/components/team/WorkItemFilterBar.tsx @@ -1,17 +1,13 @@ import { Box, Button, InputAdornment, MenuItem, TextField } from '@mui/material' import { RotateCcw, Search } from 'lucide-react' import type { TeamAgent } from '../../domain/teamAgent' -import { AgentChannelType } from '../../domain/teamAgent' -import { AgentDomainArea, AgentWorkItemKind, AgentWorkItemPriority, AgentWorkItemStatus } from '../../domain/agentWorkItem' +import { AgentDomainArea, AgentWorkItemStatus } from '../../domain/agentWorkItem' import { AgentPeriod, AgentWorkItemSort } from '../../domain/agentFilters' import { useTeamStore } from '../../stores/teamStore' import { AGENT_PERIOD_LABELS, AGENT_DOMAIN_AREA_LABELS, - AGENT_WORK_ITEM_KIND_LABELS, - AGENT_PRIORITY_LABELS, AGENT_WORK_ITEM_STATUS_LABELS, - AGENT_CHANNEL_LABELS, } from '../../lib/constants' 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 agentId = useTeamStore(s => s.historyAgentId) 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 search = useTeamStore(s => s.historySearch) const sort = useTeamStore(s => s.historySort) @@ -86,9 +79,6 @@ export function WorkItemFilterBar({ agents }: Props) { const setPeriod = useTeamStore(s => s.setHistoryPeriod) const setAgentId = useTeamStore(s => s.setHistoryAgentId) 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 setSearch = useTeamStore(s => s.setHistorySearch) const setSort = useTeamStore(s => s.setHistorySort) @@ -98,9 +88,6 @@ export function WorkItemFilterBar({ agents }: Props) { period !== AgentPeriod.ALL || agentId !== 'ALL' || area !== 'ALL' || - kind !== 'ALL' || - priority !== 'ALL' || - channel !== 'ALL' || status !== 'ALL' || search.trim() !== '' || sort !== AgentWorkItemSort.NEWEST @@ -110,22 +97,21 @@ export function WorkItemFilterBar({ agents }: Props) { return ( + {/* Zeile 1: nur die Suche — sie ist der häufigste Einstieg. */} 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={{ input: { startAdornment: ( @@ -138,6 +124,8 @@ export function WorkItemFilterBar({ agents }: Props) { }} /> + {/* Zeile 2: die verbleibenden Filter, auf dem Desktop in einer Zeile. */} + - - - - - - )} + ) } diff --git a/src/components/team/__tests__/WorkItemCard.test.tsx b/src/components/team/__tests__/WorkItemCard.test.tsx index 074fc0e..0e6cb5c 100644 --- a/src/components/team/__tests__/WorkItemCard.test.tsx +++ b/src/components/team/__tests__/WorkItemCard.test.tsx @@ -63,25 +63,27 @@ function renderCard( // ── Tests ───────────────────────────────────────────────────────────────────── 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 agent = agentOf(item) renderCard(item) 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(agent.name)).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 objectId = required(item.objectId, `Vorgang «${item.id}» hat keine Objekt-ID`) renderCard(item) 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', () => { @@ -95,14 +97,14 @@ describe('WorkItemCard — Inhalt', () => { }) 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 reason = required(item.escalationReason, `Vorgang «${item.id}» hat keinen Rückfragegrund`) expect(item.requiresDecision).toBe(true) renderCard(item) 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', () => { diff --git a/src/components/team/index.ts b/src/components/team/index.ts index 0ba079d..b56c69b 100644 --- a/src/components/team/index.ts +++ b/src/components/team/index.ts @@ -37,7 +37,13 @@ export { AgentPreviewPopover } from './AgentPreviewPopover' export { ConnectionSummaryList } from './ConnectionSummaryList' // 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 { AgentDescriptionTab } from './AgentDescriptionTab' export { RotatableAgentWheel } from './RotatableAgentWheel' @@ -45,6 +51,8 @@ export { AgentListPanel } from './AgentListPanel' export { AgentDossierHeader } from './AgentDossierHeader' export { AgentInfoBox } from './AgentInfoBox' export { AgentTasksTab } from './AgentTasksTab' +export { AgentMetricsTab } from './AgentMetricsTab' +export { AgentConnectionsTab } from './AgentConnectionsTab' export { AgentChannelsTab } from './AgentChannelsTab' export { AgentSystemsTab } from './AgentSystemsTab' export { AgentSettingsTab } from './AgentSettingsTab' diff --git a/src/pages/supply/Bearbeitungsverlauf.tsx b/src/pages/supply/Bearbeitungsverlauf.tsx index 8c84fbe..141d50e 100644 --- a/src/pages/supply/Bearbeitungsverlauf.tsx +++ b/src/pages/supply/Bearbeitungsverlauf.tsx @@ -72,7 +72,6 @@ export default function Bearbeitungsverlauf() { - + {isError ? ( refetch()} /> ) : ( <> - - {connections.filter(c => c.status === AgentConnectionStatus.CONNECTED).length} von {connections.length}{' '} - Verbindungen sind eingerichtet. - - - - {isLoading - ? [0, 1, 2, 3, 4, 5, 6, 7].map((i) => ) - : connections.map((connection) => ( - - ))} - + {isLoading ? ( + + {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => )} + + ) : ( + SECTIONS.map((section) => { + const items = connections.filter(c => section.categories.includes(c.category)) + if (items.length === 0) return null + return ( + + + {section.title} + + + {items.map((connection) => ( + + ))} + + + ) + }) + )} )} diff --git a/src/pages/supply/Personalverwaltung.tsx b/src/pages/supply/Personalverwaltung.tsx index 404f90b..5673eb4 100644 --- a/src/pages/supply/Personalverwaltung.tsx +++ b/src/pages/supply/Personalverwaltung.tsx @@ -7,8 +7,7 @@ import { AgentListPanel, AgentDossier, RotatableAgentWheel, - DEFAULT_DOSSIER_SEGMENT, - isDossierSegment, + resolveDossierSegment, } from '../../components/team' import type { DossierSegment } from '../../components/team' import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui' @@ -40,7 +39,7 @@ export default function Personalverwaltung() { const view: PersonnelView = isView(searchParams.get(VIEW_PARAM)) ? (searchParams.get(VIEW_PARAM) as PersonnelView) : 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]) // Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres diff --git a/src/pages/supply/Teamuebersicht.tsx b/src/pages/supply/Teamuebersicht.tsx index fe0d3fa..af26abe 100644 --- a/src/pages/supply/Teamuebersicht.tsx +++ b/src/pages/supply/Teamuebersicht.tsx @@ -35,6 +35,7 @@ export default function Teamuebersicht() { + {isError ? ( refetch()} />