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,22 @@
|
||||
import { Chip } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
confidence: number
|
||||
size?: 'small' | 'medium'
|
||||
}
|
||||
|
||||
function confidenceColor(c: number): string {
|
||||
if (c >= 0.8) return '#1a7a4a'
|
||||
if (c >= 0.6) return '#d97706'
|
||||
return '#c0392b'
|
||||
}
|
||||
|
||||
export function ConfidenceFieldBadge({ confidence, size = 'small' }: Props) {
|
||||
return (
|
||||
<Chip
|
||||
label={`${Math.round(confidence * 100)}%`}
|
||||
size={size}
|
||||
sx={{ bgcolor: confidenceColor(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { ExtractedFieldRow } from './ExtractedFieldRow'
|
||||
|
||||
interface Props {
|
||||
result: ParseNeedResult
|
||||
criteria: ParsedNeedCriteria
|
||||
onCriteriaChange: (c: ParsedNeedCriteria) => void
|
||||
}
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||
}
|
||||
|
||||
// ── Parse helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function parseAreaRange(s: string): ParsedNeedCriteria['areaRange'] {
|
||||
const m = s.match(/(\d+)\s*[–\-]\s*(\d+)/)
|
||||
if (m) return { min: parseInt(m[1]), max: parseInt(m[2]) }
|
||||
const n = s.match(/(\d+)/)
|
||||
if (n) { const v = parseInt(n[1]); return { min: Math.round(v * 0.8), max: Math.round(v * 1.2) } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseBudget(s: string): ParsedNeedCriteria['budgetRange'] {
|
||||
const n = s.match(/(\d+)/)
|
||||
if (!n) return undefined
|
||||
return { maxPerSqm: parseInt(n[1]), currency: 'CHF' }
|
||||
}
|
||||
|
||||
function parseList(s: string): string[] {
|
||||
return s.split(',').map(x => x.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function displayAreaRange(v: ParsedNeedCriteria['areaRange']): string {
|
||||
return v ? `${v.min}–${v.max} m²` : ''
|
||||
}
|
||||
function displayBudget(v: ParsedNeedCriteria['budgetRange']): string {
|
||||
return v ? `CHF ${v.maxPerSqm}/m²` : ''
|
||||
}
|
||||
function displayTiming(v: ParsedNeedCriteria['timing']): string {
|
||||
if (!v) return ''
|
||||
return `ab ${v.earliestMoveIn}${v.flexibleTiming ? ' (flexibel)' : ''}`
|
||||
}
|
||||
|
||||
// ── Section wrapper ────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="overline" sx={{ fontWeight: 700, color: '#64748b', fontSize: 10, letterSpacing: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set }: Props) {
|
||||
const { confidenceByField: conf, missingFields, assumptions } = result
|
||||
|
||||
return (
|
||||
<Card sx={{ p: 3, height: '100%', overflowY: 'auto' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Extrahierte Kriterien
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
{result.rawSummary}
|
||||
</Typography>
|
||||
|
||||
{/* ── Hard Facts ──────────────────────────────────────────────────── */}
|
||||
<Section title="Hard Facts">
|
||||
<ExtractedFieldRow
|
||||
label="Nutzungstyp"
|
||||
value={c.assetType ? (ASSET_LABELS[c.assetType] ?? c.assetType) : ''}
|
||||
confidence={conf.assetType ?? 0.2}
|
||||
missing={!c.assetType}
|
||||
onEdit={v => set({ ...c, assetType: (v.toUpperCase() as ParsedNeedCriteria['assetType']) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Flächenbedarf (z.B. 500–800)"
|
||||
value={displayAreaRange(c.areaRange)}
|
||||
confidence={conf.areaRange ?? 0.2}
|
||||
missing={!c.areaRange}
|
||||
onEdit={v => set({ ...c, areaRange: parseAreaRange(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Standort (Komma-getrennt)"
|
||||
value={c.preferredLocations?.join(', ') ?? ''}
|
||||
confidence={conf.preferredLocations ?? 0.15}
|
||||
missing={!c.preferredLocations?.length}
|
||||
onEdit={v => set({ ...c, preferredLocations: parseList(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Budget (max CHF/m²)"
|
||||
value={displayBudget(c.budgetRange)}
|
||||
confidence={conf.budgetRange ?? 0.2}
|
||||
missing={!c.budgetRange}
|
||||
onEdit={v => set({ ...c, budgetRange: parseBudget(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Verfügbarkeit (ab Datum)"
|
||||
value={displayTiming(c.timing)}
|
||||
confidence={conf.timing ?? 0.2}
|
||||
missing={!c.timing}
|
||||
onEdit={v => set({ ...c, timing: { earliestMoveIn: v, latestMoveIn: v, flexibleTiming: v.toLowerCase().includes('flex') } })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── Must-haves ──────────────────────────────────────────────────── */}
|
||||
<Section title="Must-haves">
|
||||
<ExtractedFieldRow
|
||||
label="Pflichtkriterien (Komma-getrennt)"
|
||||
value={c.mustHaveCriteria?.join(', ') ?? ''}
|
||||
missing={!c.mustHaveCriteria?.length}
|
||||
onEdit={v => set({ ...c, mustHaveCriteria: parseList(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Parkplatzbedarf"
|
||||
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
|
||||
missing={c.parkingNeed === undefined}
|
||||
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── AI Assumptions ──────────────────────────────────────────────── */}
|
||||
{assumptions.length > 0 && (
|
||||
<Section title="KI-Annahmen">
|
||||
<Stack spacing={0.5}>
|
||||
{assumptions.map((a, i) => (
|
||||
<Alert key={i} severity="warning" sx={{ py: 0, px: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
|
||||
{a}
|
||||
</Alert>
|
||||
))}
|
||||
</Stack>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Missing Information ─────────────────────────────────────────── */}
|
||||
{missingFields.length > 0 && (
|
||||
<Section title="Fehlende Angaben">
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{missingFields.map(f => (
|
||||
<Chip key={f} label={f} size="small" color="warning" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Section>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, IconButton, TextField, Typography } from '@mui/material'
|
||||
import { Pencil } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string
|
||||
confidence?: number
|
||||
missing?: boolean
|
||||
onEdit?: (value: string) => void
|
||||
}
|
||||
|
||||
export function ExtractedFieldRow({ label, value, missing = false, onEdit }: Props) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
|
||||
function commit() {
|
||||
setEditing(false)
|
||||
if (editValue !== value) onEdit?.(editValue)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Box sx={{ py: 1, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={editValue}
|
||||
autoFocus
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commit() }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
py: 1,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
opacity: missing ? 0.55 : 1,
|
||||
'&:hover .edit-btn': { visibility: 'visible' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontStyle: missing ? 'italic' : 'normal' }}>
|
||||
{value || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
size="small"
|
||||
className="edit-btn"
|
||||
sx={{ visibility: 'hidden', p: 0.25, flexShrink: 0 }}
|
||||
onClick={() => { setEditValue(value); setEditing(true) }}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Box, Button, Card, Typography } from '@mui/material'
|
||||
import { ArrowRight, RefreshCw } from 'lucide-react'
|
||||
import type { FollowUpQuestion } from '../../domain/needBuilder'
|
||||
import { FollowUpQuestionCard } from './FollowUpQuestionCard'
|
||||
|
||||
interface Props {
|
||||
questions: FollowUpQuestion[]
|
||||
answers: Record<string, string>
|
||||
onAnswer: (id: string, answer: string) => void
|
||||
onContinue: () => void
|
||||
onReparse?: () => void
|
||||
}
|
||||
|
||||
export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onReparse }: Props) {
|
||||
const requiredUnanswered = questions
|
||||
.filter(q => q.importance === 'required')
|
||||
.filter(q => !answers[q.id])
|
||||
|
||||
const sorted = [
|
||||
...questions.filter(q => q.importance === 'required'),
|
||||
...questions.filter(q => q.importance === 'recommended'),
|
||||
...questions.filter(q => q.importance === 'optional'),
|
||||
]
|
||||
|
||||
return (
|
||||
<Card sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Rückfragen der KI
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Beantworten Sie die Pflichtfelder für optimale Ergebnisse. Optionale Fragen können übersprungen werden.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{sorted.map(q => (
|
||||
<FollowUpQuestionCard
|
||||
key={q.id}
|
||||
question={q}
|
||||
answer={answers[q.id] ?? ''}
|
||||
onAnswer={ans => onAnswer(q.id, ans)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ pt: 2, mt: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{requiredUnanswered.length > 0 && (
|
||||
<Typography variant="caption" color="error">
|
||||
{requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er fehlen' : ' fehlt'} noch.
|
||||
</Typography>
|
||||
)}
|
||||
{onReparse && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<RefreshCw size={14} />}
|
||||
onClick={onReparse}
|
||||
>
|
||||
Rückfragen neu generieren
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={requiredUnanswered.length > 0}
|
||||
onClick={onContinue}
|
||||
endIcon={<ArrowRight size={16} />}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
Weiter zur Gewichtung
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Box, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||
import type { FollowUpQuestion } from '../../domain/needBuilder'
|
||||
|
||||
interface Props {
|
||||
question: FollowUpQuestion
|
||||
answer: string
|
||||
onAnswer: (answer: string) => void
|
||||
}
|
||||
|
||||
const IMPORTANCE_LABEL: Record<FollowUpQuestion['importance'], string> = {
|
||||
required: 'Pflichtfeld',
|
||||
recommended: 'Empfohlen',
|
||||
optional: 'Optional',
|
||||
}
|
||||
|
||||
const IMPORTANCE_COLOR: Record<FollowUpQuestion['importance'], 'error' | 'warning' | 'default'> = {
|
||||
required: 'error',
|
||||
recommended: 'warning',
|
||||
optional: 'default',
|
||||
}
|
||||
|
||||
export function FollowUpQuestionCard({ question, answer, onAnswer }: Props) {
|
||||
return (
|
||||
<Box sx={{ pb: 2, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, flex: 1 }}>
|
||||
{question.questionText}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={IMPORTANCE_LABEL[question.importance]}
|
||||
size="small"
|
||||
color={IMPORTANCE_COLOR[question.importance]}
|
||||
variant="outlined"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
{question.reason}
|
||||
</Typography>
|
||||
|
||||
{question.suggestedAnswerOptions && question.suggestedAnswerOptions.length > 0 ? (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{question.suggestedAnswerOptions.map(opt => (
|
||||
<Chip
|
||||
key={opt}
|
||||
label={opt}
|
||||
size="small"
|
||||
clickable
|
||||
variant={answer === opt ? 'filled' : 'outlined'}
|
||||
color={answer === opt ? 'primary' : 'default'}
|
||||
onClick={() => onAnswer(answer === opt ? '' : opt)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Ihre Antwort (optional)"
|
||||
value={answer}
|
||||
onChange={e => onAnswer(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import { AlertTriangle, RotateCcw } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function NeedBuilderErrorState({ message, onRetry }: Props) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 12, gap: 3, maxWidth: 480, mx: 'auto' }}>
|
||||
<AlertTriangle size={48} color="#c0392b" />
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Analyse fehlgeschlagen
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{message}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RotateCcw size={16} />}
|
||||
onClick={onRetry}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Box, Stepper, Step, StepLabel } from '@mui/material'
|
||||
import type { NeedBuilderStep } from '../../domain/needBuilder'
|
||||
import { NeedBuilderStep as S } from '../../domain/needBuilder'
|
||||
|
||||
interface Props {
|
||||
step: NeedBuilderStep
|
||||
}
|
||||
|
||||
const STEPS = ['Suchkriterien & Gewichtung', 'Vorschau & Speichern']
|
||||
|
||||
function toStepIndex(step: NeedBuilderStep): number {
|
||||
if (
|
||||
step === S.IDLE ||
|
||||
step === S.PARSING ||
|
||||
step === S.PARSED_REQUIRES_REVIEW ||
|
||||
step === S.CLARIFICATION_REQUIRED
|
||||
) return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
export function NeedBuilderProgress({ step }: Props) {
|
||||
if (step === S.IDLE) return null
|
||||
return (
|
||||
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc' }}>
|
||||
<Stepper activeStep={toStepIndex(step)} alternativeLabel>
|
||||
{STEPS.map(label => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Box, Card, Chip, Divider, LinearProgress, Stack, TextField, Typography, Alert } from '@mui/material'
|
||||
import { MapPin, Ruler, Wallet, Clock, CheckSquare, ShieldAlert } from 'lucide-react'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
|
||||
import type { WeightingKey } from '../../domain/needBuilder'
|
||||
|
||||
interface Props {
|
||||
criteria: ParsedNeedCriteria
|
||||
weights: Record<WeightingKey, number>
|
||||
confidenceByField: Record<string, number>
|
||||
missingFields: string[]
|
||||
needTitle: string
|
||||
onNeedTitleChange: (v: string) => void
|
||||
}
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
||||
}
|
||||
|
||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||
|
||||
export function NeedCardPreview({ criteria: c, weights, confidenceByField, missingFields, needTitle, onNeedTitleChange }: Props) {
|
||||
const maxWeight = Math.max(...WEIGHTING_KEYS.map(k => weights[k] ?? 0), 0.01)
|
||||
|
||||
const fieldEntries = Object.entries(confidenceByField)
|
||||
const overallConfidence = fieldEntries.length > 0
|
||||
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
|
||||
: 0
|
||||
const lowConfidenceFields = fieldEntries.filter(([, v]) => v < 0.6).map(([k]) => k)
|
||||
const criticalMissing = missingFields.filter(f => CRITICAL_FIELDS.some(cf => f.toLowerCase().includes(cf.toLowerCase())))
|
||||
const isLowConfidence = overallConfidence < 0.6
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Vorschau — Neuer Bedarf
|
||||
</Typography>
|
||||
|
||||
{/* Need Title */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Bedarf Bezeichnung"
|
||||
placeholder="z.B. Bürofläche Zürich Q4/2025"
|
||||
value={needTitle}
|
||||
onChange={e => onNeedTitleChange(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* Low-confidence warning */}
|
||||
{isLowConfidence && (
|
||||
<Alert severity="warning" icon={<ShieldAlert size={18} />} sx={{ mb: 2 }}>
|
||||
Gesamtkonfidenz niedrig ({Math.round(overallConfidence * 100)}%) — Bedarf wird als Entwurf gespeichert und muss manuell geprüft werden.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Critical missing fields */}
|
||||
{criticalMissing.length > 0 && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Fehlende Pflichtfelder: {criticalMissing.join(', ')}. Bitte in den Kriterien ergänzen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card sx={{ p: 3, mb: 2 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
{c.assetType && (
|
||||
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white' }} />
|
||||
)}
|
||||
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 2 }}>
|
||||
{c.preferredLocations && c.preferredLocations.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<MapPin size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Standort</Typography>
|
||||
<Typography variant="body2">{c.preferredLocations.join(', ')}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{c.areaRange && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Ruler size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Fläche</Typography>
|
||||
<Typography variant="body2">{c.areaRange.min}–{c.areaRange.max} m²</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{c.budgetRange && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Wallet size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Budget</Typography>
|
||||
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{c.timing && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Clock size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Verfügbarkeit</Typography>
|
||||
<Typography variant="body2">ab {c.timing.earliestMoveIn}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
|
||||
<CheckSquare size={16} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">Must-haves</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{c.mustHaveCriteria.map(m => (
|
||||
<Chip key={m} label={m} size="small" variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Confidence Summary */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }} color="text.secondary">
|
||||
Gesamtkonfidenz
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b' }}
|
||||
>
|
||||
{Math.round(overallConfidence * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={overallConfidence * 100}
|
||||
sx={{
|
||||
height: 6, borderRadius: 3, bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{lowConfidenceFields.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
Unsichere Felder: {lowConfidenceFields.join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Weights */}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
|
||||
Gewichtungsprofil
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{WEIGHTING_KEYS.map(k => {
|
||||
const pct = Math.round((weights[k] ?? 0) * 100)
|
||||
return (
|
||||
<Box key={k} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ width: 130, flexShrink: 0 }}>{WEIGHTING_LABELS[k]}</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={((weights[k] ?? 0) / maxWeight) * 100}
|
||||
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
|
||||
interface Props {
|
||||
criteria: ParsedNeedCriteria
|
||||
onCriteriaChange: (c: ParsedNeedCriteria) => void
|
||||
}
|
||||
|
||||
const ASSET_OPTIONS = [
|
||||
{ label: 'Büro', value: AssetType.OFFICE },
|
||||
{ label: 'Retail', value: AssetType.RETAIL },
|
||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||
]
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
const [locationDraft, setLocationDraft] = useState('')
|
||||
const [mustHaveDraft, setMustHaveDraft] = useState('')
|
||||
|
||||
function addLocations(raw: string) {
|
||||
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
|
||||
if (!tokens.length) return
|
||||
set({ ...c, preferredLocations: [...new Set([...(c.preferredLocations ?? []), ...tokens])] })
|
||||
setLocationDraft('')
|
||||
}
|
||||
|
||||
function addMustHaves(raw: string) {
|
||||
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
|
||||
if (!tokens.length) return
|
||||
set({ ...c, mustHaveCriteria: [...new Set([...(c.mustHaveCriteria ?? []), ...tokens])] })
|
||||
setMustHaveDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Card elevation={0} sx={{ p: 3, border: '1px solid #e2e8f0', height: '100%' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>Kriterien verfeinern</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2.5 }}>
|
||||
Ergänzen oder korrigieren Sie die extrahierten Felder.
|
||||
</Typography>
|
||||
|
||||
{/* Asset Type */}
|
||||
<FieldLabel>Nutzungstyp</FieldLabel>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
|
||||
{ASSET_OPTIONS.map(opt => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
size="small"
|
||||
variant={c.assetType === opt.value ? 'filled' : 'outlined'}
|
||||
clickable
|
||||
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
|
||||
sx={c.assetType === opt.value
|
||||
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
|
||||
: {}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Area */}
|
||||
<FieldLabel>Fläche (m²)</FieldLabel>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
|
||||
<TextField
|
||||
size="small" type="number" placeholder="Min"
|
||||
value={c.areaRange?.min || ''}
|
||||
onChange={e => set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })}
|
||||
sx={{ width: 100 }}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">–</Typography>
|
||||
<TextField
|
||||
size="small" type="number" placeholder="Max"
|
||||
value={c.areaRange?.max || ''}
|
||||
onChange={e => set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })}
|
||||
sx={{ width: 100 }}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">m²</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Location */}
|
||||
<FieldLabel>Standort</FieldLabel>
|
||||
<TextField
|
||||
size="small" fullWidth
|
||||
placeholder="Stadt oder Region — Enter zum Hinzufügen"
|
||||
value={locationDraft}
|
||||
onChange={e => setLocationDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && locationDraft.trim()) addLocations(locationDraft) }}
|
||||
onBlur={() => { if (locationDraft.trim()) addLocations(locationDraft) }}
|
||||
sx={{ mb: 0.75 }}
|
||||
/>
|
||||
{(c.preferredLocations?.length ?? 0) > 0 ? (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
|
||||
{c.preferredLocations!.map(loc => (
|
||||
<Chip key={loc} label={loc} size="small"
|
||||
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : <Box sx={{ mb: 2.5 }} />}
|
||||
|
||||
{/* Budget */}
|
||||
<FieldLabel>Budget (max CHF/m²)</FieldLabel>
|
||||
<TextField
|
||||
size="small" type="number" placeholder="z.B. 45"
|
||||
value={c.budgetRange?.maxPerSqm || ''}
|
||||
onChange={e => set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })}
|
||||
sx={{ width: 160, mb: 2.5 }}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
|
||||
{/* Timing */}
|
||||
<FieldLabel>Verfügbar ab</FieldLabel>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="z.B. Q3 2025 oder 01.09.2025"
|
||||
value={c.timing?.earliestMoveIn ?? ''}
|
||||
onChange={e => set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })}
|
||||
sx={{ width: 220, mb: 2.5 }}
|
||||
/>
|
||||
|
||||
{/* Must-haves */}
|
||||
<FieldLabel>Must-haves</FieldLabel>
|
||||
<TextField
|
||||
size="small" fullWidth
|
||||
placeholder="z.B. ÖV-Anbindung, Parkplätze — Enter zum Hinzufügen"
|
||||
value={mustHaveDraft}
|
||||
onChange={e => setMustHaveDraft(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
|
||||
onBlur={() => { if (mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
|
||||
sx={{ mb: 0.75 }}
|
||||
/>
|
||||
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{c.mustHaveCriteria!.map(item => (
|
||||
<Chip key={item} label={item} size="small"
|
||||
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, Card, Slider, Typography } from '@mui/material'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
|
||||
import type { WeightingKey } from '../../domain/needBuilder'
|
||||
import { weightingService } from '../../services/weightingService'
|
||||
|
||||
interface Props {
|
||||
weights: Record<WeightingKey, number>
|
||||
onChange: (weights: Record<WeightingKey, number>) => void
|
||||
assetType?: string
|
||||
}
|
||||
|
||||
const IMPORTANCE_LABELS = ['', 'Unwichtig', 'Wenig wichtig', 'Wichtig', 'Sehr wichtig', 'Entscheidend']
|
||||
|
||||
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
|
||||
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
|
||||
if (max === 0) return Object.fromEntries(WEIGHTING_KEYS.map(k => [k, 3])) as Record<WeightingKey, number>
|
||||
return Object.fromEntries(
|
||||
WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))])
|
||||
) as Record<WeightingKey, number>
|
||||
}
|
||||
|
||||
function rawToWeights(raw: Record<WeightingKey, number>): Record<WeightingKey, number> {
|
||||
const total = WEIGHTING_KEYS.reduce((s, k) => s + (raw[k] ?? 1), 0)
|
||||
return Object.fromEntries(
|
||||
WEIGHTING_KEYS.map(k => [k, (raw[k] ?? 1) / total])
|
||||
) as Record<WeightingKey, number>
|
||||
}
|
||||
|
||||
export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
|
||||
|
||||
function handleSlider(key: WeightingKey, value: number) {
|
||||
const updated = { ...raw, [key]: value }
|
||||
setRaw(updated)
|
||||
onChange(rawToWeights(updated))
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
const defaults = weightingService.getDefaultWeights(assetType)
|
||||
setRaw(toRaw(defaults))
|
||||
onChange(defaults)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Wichtigkeit der Kriterien
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RotateCcw size={14} />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{WEIGHTING_KEYS.map(key => {
|
||||
const importance = raw[key] ?? 3
|
||||
return (
|
||||
<Box key={key}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{WEIGHTING_LABELS[key]}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{IMPORTANCE_LABELS[importance]}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={importance}
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
marks
|
||||
onChange={(_, v) => handleSlider(key, v as number)}
|
||||
size="small"
|
||||
sx={{ color: '#1e3a5f' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
|
||||
export { CriteriaReviewPanel } from './CriteriaReviewPanel'
|
||||
export { ExtractedFieldRow } from './ExtractedFieldRow'
|
||||
export { FollowUpPanel } from './FollowUpPanel'
|
||||
export { FollowUpQuestionCard } from './FollowUpQuestionCard'
|
||||
export { NeedBuilderErrorState } from './NeedBuilderErrorState'
|
||||
export { NeedBuilderProgress } from './NeedBuilderProgress'
|
||||
export { NeedCardPreview } from './NeedCardPreview'
|
||||
export { NeedInput } from './NeedInput'
|
||||
export { VoiceNeedInput } from './VoiceNeedInput'
|
||||
export { WeightingEditor } from './WeightingEditor'
|
||||
Reference in New Issue
Block a user