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>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
|
||||
import { Mic, MicOff, Sparkles, X } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
onTextChange: (s: string) => void
|
||||
onAiSubmit: () => void
|
||||
isAnalyzing: boolean
|
||||
isAutoGen: boolean
|
||||
}
|
||||
|
||||
const EXAMPLES = [
|
||||
'Büro 800–1000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
|
||||
'Retail-Fläche 200–400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
|
||||
'Lagerhalle 2000–3000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
|
||||
]
|
||||
|
||||
const isSpeechSupported = typeof window !== 'undefined' &&
|
||||
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
|
||||
|
||||
export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [interimText, setInterimText] = useState('')
|
||||
const recognitionRef = useRef<any>(null)
|
||||
const accumulatedRef = useRef('')
|
||||
|
||||
function startRecording() {
|
||||
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition
|
||||
if (!SpeechAPI) return
|
||||
|
||||
accumulatedRef.current = text
|
||||
const rec = new SpeechAPI()
|
||||
rec.lang = 'de-DE'
|
||||
rec.continuous = true
|
||||
rec.interimResults = true
|
||||
|
||||
rec.onresult = (e: any) => {
|
||||
let finalPart = ''
|
||||
let interimPart = ''
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const t = e.results[i][0].transcript
|
||||
if (e.results[i].isFinal) finalPart += t
|
||||
else interimPart += t
|
||||
}
|
||||
if (finalPart) {
|
||||
accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim()
|
||||
onTextChange(accumulatedRef.current)
|
||||
}
|
||||
setInterimText(interimPart)
|
||||
}
|
||||
|
||||
rec.onend = () => {
|
||||
setIsRecording(false)
|
||||
setInterimText('')
|
||||
if (accumulatedRef.current.length >= 15) onAiSubmit()
|
||||
}
|
||||
|
||||
rec.onerror = () => { setIsRecording(false); setInterimText('') }
|
||||
rec.start()
|
||||
recognitionRef.current = rec
|
||||
setIsRecording(true)
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
recognitionRef.current?.stop()
|
||||
}
|
||||
|
||||
// Show interim text inside the field while recording
|
||||
const displayValue = isRecording && interimText
|
||||
? (text + (text ? ' ' : '') + interimText)
|
||||
: text
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
border: '1px solid',
|
||||
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
|
||||
transition: 'border-color 0.2s',
|
||||
bgcolor: isRecording ? '#fff5f5' : 'white',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
|
||||
Bedarf beschreiben
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Schreiben oder sprechen — die KI extrahiert alle Kriterien automatisch
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isRecording ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label="🎤 Aufnahme läuft…"
|
||||
onClick={stopRecording}
|
||||
sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
|
||||
/>
|
||||
) : isAnalyzing ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
|
||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert…</Typography>
|
||||
</Box>
|
||||
) : isAutoGen && text ? (
|
||||
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}>⚡ auto-synchronisiert</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{/* Textarea + mic */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<TextField
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
placeholder="Wir suchen 800–1.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
|
||||
value={displayValue}
|
||||
onChange={e => {
|
||||
setInterimText('')
|
||||
onTextChange(e.target.value)
|
||||
}}
|
||||
disabled={isRecording || isAnalyzing}
|
||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
pr: '52px',
|
||||
bgcolor: isAutoGen && !isRecording ? '#f0f7ff' : 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
'& textarea': { color: isRecording && interimText ? '#64748b' : 'inherit' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', bottom: 10, right: 10 }}>
|
||||
{isRecording ? (
|
||||
<IconButton
|
||||
onClick={stopRecording}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' },
|
||||
animation: 'micPulse 1.2s ease-in-out infinite',
|
||||
'@keyframes micPulse': {
|
||||
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' },
|
||||
'50%': { boxShadow: '0 0 0 6px rgba(220,38,38,0)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MicOff size={16} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={isSpeechSupported ? startRecording : undefined}
|
||||
size="small"
|
||||
disabled={isAnalyzing || !isSpeechSupported}
|
||||
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
|
||||
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
|
||||
>
|
||||
<Mic size={16} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5 }}>
|
||||
{/* Example prompts */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{EXAMPLES.map((ex, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={ex.slice(0, 32) + '…'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onTextChange(ex)}
|
||||
sx={{ fontSize: 10, height: 20 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
|
||||
{text && !isRecording && (
|
||||
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}>
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!text.trim() || isAnalyzing || isRecording}
|
||||
onClick={onAiSubmit}
|
||||
endIcon={<Sparkles size={13} />}
|
||||
sx={{ fontSize: 12, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
KI Auto-fill
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user