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) && (
- }
- onClick={runApprove}
- sx={{ textTransform: 'none', fontWeight: 600 }}
- >
- Mit einem Klick bestätigen
-
- )}
+ {item.requiresDecision ? (
+ <>
+ }
+ onClick={() => setConfirmApprove(true)}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Freigeben
+
- {can(AgentWorkItemAction.APPROVE) && (
- }
- onClick={() => setDialog('approve')}
- sx={{ textTransform: 'none', fontWeight: 600 }}
- >
- Freigeben
-
- )}
+ }
+ onClick={() => setConfirmReject(true)}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Zurückweisen
+
- {can(AgentWorkItemAction.DECIDE) && (
- }
- onClick={() => setDialog('decide')}
- sx={{ textTransform: 'none', fontWeight: 600 }}
- >
- Entscheidung treffen
-
- )}
+ {hasSources && (
+ }
+ onClick={onShowSources}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Quellen anzeigen
+
+ )}
- {can(AgentWorkItemAction.ANSWER_QUERY) && (
- }
- onClick={() => setDialog('answer')}
- sx={{ textTransform: 'none', fontWeight: 600 }}
- >
- Rückfrage beantworten
-
- )}
-
- {can(AgentWorkItemAction.EDIT) && (item.editableFields?.length ?? 0) > 0 && (
- }
- onClick={() => setDialog('edit')}
- sx={{ textTransform: 'none', fontWeight: 600 }}
- >
- Anpassen
-
- )}
-
- {can(AgentWorkItemAction.REJECT) && (
- }
- onClick={() => setDialog('reject')}
- sx={{ textTransform: 'none', fontWeight: 600 }}
- >
- Zurückweisen
-
- )}
-
- {can(AgentWorkItemAction.MARK_DONE) && (
- }
- onClick={() => markDone.mutate(item.id)}
- sx={{ textTransform: 'none', fontWeight: 600 }}
- >
- Als erledigt markieren
-
- )}
-
- {can(AgentWorkItemAction.OPEN_SOURCE) && item.sourceReferences.length > 0 && (
- }
- onClick={onOpenSource}
- sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
- >
- Quelle öffnen
-
+ {hasProperty && (
+ }
+ onClick={onOpenProperty}
+ sx={{ textTransform: 'none', fontWeight: 600, ml: 'auto' }}
+ >
+ Inserat anzeigen
+
+ )}
+ >
+ ) : (
+ hasProperty && (
+ }
+ onClick={onOpenProperty}
+ sx={{ textTransform: 'none', fontWeight: 600 }}
+ >
+ Objekt anzeigen
+
+ )
)}
-
- {
+ 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
+
+ }
+ onClick={openCorrespondence}
+ sx={{ textTransform: 'none', fontWeight: 600, mt: 0.5, px: 0 }}
+ >
+ Gesamte Korrespondenz im Anfragencenter anzeigen
+
)}
{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()} />