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()} />