Files
property-match/.claude/worktrees/agent-a82a3716/src/components/assistant/GlobalAIAssistantDrawer.tsx
T
Benjamin Sutter d15a13e485 feat: remove Administration workspace — keep only Verwaltung + Suche
- Delete all ops page components (ReviewQueue, AIMonitoring, Governance,
  SourceMonitoring, ActivityTimeline, SignalPipeline)
- Remove OPERATIONS workspace from AppShell config, nav order, path detection
- Remove all /ops/* routes from App.tsx
- Remove WorkspaceType.OPERATIONS from allowedWorkspaces in authService,
  sessionStore, permissions
- Keep MarketIntelligence page (already moved to /supply/market-intelligence)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:32:41 +02:00

265 lines
9.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material'
import { RotateCcw, Send, Sparkles, X } from 'lucide-react'
import { useLocation } from 'react-router'
import { useAssistantStore } from '../../stores/assistantStore'
import { useSessionStore } from '../../stores/sessionStore'
import { aiAssistantService } from '../../services/aiAssistantService'
import { AssistantContextSummary } from './AssistantContextSummary'
import { AssistantMessageList } from './AssistantMessageList'
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant'
import type { WorkspaceType } from '../../domain/enums'
function resolveWorkspace(pathname: string): WorkspaceType | null {
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
if (pathname.startsWith('/demand')) return 'DEMAND' as WorkspaceType
if (pathname.startsWith('/ops')) return 'OPERATIONS' as WorkspaceType
return null
}
export function GlobalAIAssistantDrawer() {
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
useAssistantStore()
const { currentUser } = useSessionStore()
const location = useLocation()
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
const [inputText, setInputText] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
// Build context from route when drawer opens
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [isOpen, location.pathname])
// Refresh suggestions when route changes while open
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [location.pathname])
// Auto-scroll on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isLoading])
const handleQuestion = useCallback(async (question: string) => {
if (!question.trim() || isLoading) return
setInputText('')
setError(null)
const userMsg = {
id: crypto.randomUUID(),
role: 'user' as const,
content: question.trim(),
createdAt: new Date().toISOString(),
}
addMessage(userMsg)
setLoading(true)
try {
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
const answer = await aiAssistantService.answerQuestion(ctx, question)
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
} catch {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
} finally {
setLoading(false)
}
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuestion(inputText)
}
}
const handleClear = () => {
clearConversation()
setSuggestions([])
if (context) {
aiAssistantService.getSuggestions(context).then(setSuggestions)
}
}
const showSuggestions = suggestions.length > 0 && messages.length === 0
return (
<Drawer
anchor="right"
open={isOpen}
onClose={close}
variant="temporary"
slotProps={{
paper: {
sx: {
width: 420,
top: '56px',
height: 'calc(100% - 56px)',
boxShadow: '-4px 0 24px rgba(0,0,0,0.12)',
display: 'flex',
flexDirection: 'column',
},
},
}}
>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
>
<Sparkles size={14} color="white" />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.2 }}>
AI Assistent
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
Kontextbasierte Entscheidungsunterstützung
</Typography>
</Box>
<Tooltip title="Gespräch zurücksetzen">
<IconButton size="small" onClick={handleClear} disabled={messages.length === 0} sx={{ color: '#94a3b8' }}>
<RotateCcw size={14} />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={close} sx={{ color: '#94a3b8' }}>
<X size={16} />
</IconButton>
</Box>
{/* Context summary */}
{context && <AssistantContextSummary context={context} />}
{/* Scrollable body */}
<Box
ref={scrollRef}
sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
>
{/* Welcome message */}
{messages.length === 0 && !isLoading && (
<Box sx={{ px: 2, py: 1.5 }}>
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}>
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: '#334155', lineHeight: 1.6 }}>
Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten keine erfundenen Fakten.
</Typography>
</Box>
</Box>
)}
{/* Suggestions */}
{showSuggestions && (
<>
<AssistantPromptSuggestions
suggestions={suggestions}
onSelect={handleQuestion}
disabled={isLoading}
/>
<Divider sx={{ mx: 2, my: 0.5 }} />
</>
)}
{/* Messages */}
{messages.length > 0 && (
<Box sx={{ py: 0.5 }}>
<AssistantMessageList messages={messages} />
</Box>
)}
{/* Inline suggestions after messages */}
{messages.length > 0 && suggestions.length > 0 && !isLoading && (
<>
<Divider sx={{ mx: 2, my: 0.5 }} />
<AssistantPromptSuggestions
suggestions={suggestions.slice(0, 2)}
onSelect={handleQuestion}
disabled={isLoading}
/>
</>
)}
{/* Loading */}
{isLoading && <AssistantLoadingState />}
{/* Error */}
{error && <AssistantErrorState error={error} onRetry={() => setError(null)} />}
</Box>
{/* Input area */}
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
<TextField
multiline
maxRows={4}
fullWidth
size="small"
placeholder="Frage stellen…"
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
sx={{
'& .MuiOutlinedInput-root': { fontSize: '0.8125rem', borderRadius: 2 },
}}
/>
<Tooltip title="Senden (Enter)">
<span>
<IconButton
onClick={() => handleQuestion(inputText)}
disabled={!inputText.trim() || isLoading}
sx={{
bgcolor: '#4f46e5',
color: 'white',
flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' },
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
}}
>
<Send size={16} />
</IconButton>
</span>
</Tooltip>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontSize: '0.65rem', textAlign: 'center' }}>
Antworten sind datenbasiert Aktionen erfordern manuelle Bestätigung
</Typography>
</Box>
</Drawer>
)
}