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