Files
property-match/src/components/assistant/GlobalAIAssistantDrawer.tsx
T
Benjamin Sutter da50f3b5ea feat: premium redesign — DM Serif, refined palette, flat score badges
- Design tokens (ds.ts, theme.ts, scoreTheme.ts): new warm palette
  (#152642 navy, #f9f8f6 warm white, #e8e7e4 borders, #b8975a gold accent),
  flat score tier badges replacing CSS gradients, Inter + DM Serif Display typography
- Card components: white-background cards, DM Serif score numbers, max-2 badge
  chips with +N overflow tooltip, editorial score badge positioning
- Layout shell: gold left-accent nav active state, 64px top bar, outlined
  workspace chip, DM Serif page titles
- Shared atoms: GenericBadge (outlined/solid variants), ResultFilterBar
  (simplified chip styles), DecisionContextPanel (dot metrics, no left accent)
- Global replacement (118 files): #1e3a5f→#152642, #e2e8f0→#e8e7e4,
  #f4f6f9→#f9f8f6 — all handled via Node.js for proper UTF-8 safety

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:26:30 +02:00

255 lines
8.6 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 { useShallow } from 'zustand/react/shallow'
import { useAssistantStore } from '../../stores/assistantStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useAssistantSuggestions, useAssistantAnswer } from '../../hooks/useAssistant'
import { AssistantContextSummary } from './AssistantContextSummary'
import { AssistantMessageList } from './AssistantMessageList'
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext } 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
return null
}
export function GlobalAIAssistantDrawer() {
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
useAssistantStore(useShallow(s => s))
const { currentUser } = useSessionStore()
const location = useLocation()
const [inputText, setInputText] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
const answerQuestion = useAssistantAnswer()
const { data: suggestions = [] } = useAssistantSuggestions(isOpen ? context : null)
// Build context from route when drawer opens or 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)
}, [isOpen, location.pathname])
// Auto-scroll on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isLoading])
const handleQuestion = useCallback((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)
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
answerQuestion.mutate(
{ context: ctx, question },
{
onSuccess: (answer) => {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
setLoading(false)
},
onError: () => {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
setLoading(false)
},
},
)
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError, answerQuestion])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuestion(inputText)
}
}
const handleClear = () => {
clearConversation()
}
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: '#e8e7e4', 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>
)
}