feat: F008 AI need builder — smart parse, criteria review, follow-up & weighting
Domain: ParsedNeedCriteria, FollowUpQuestion, ParseNeedResult, NeedBuilderStep, WEIGHTING_KEYS/LABELS. Services: weightingService (asset-type profiles), aiService extended with parseNeed (keyword extraction mock, 1.4s delay) and generateFollowUpQuestions. Components: NeedBuilderProgress, NeedInput, ConfidenceFieldBadge, ExtractedFieldRow, CriteriaReviewPanel, FollowUpQuestionCard, FollowUpPanel, WeightingEditor, NeedCardPreview, NeedBuilderErrorState. Page: AINeedBuilderPage replaces AISearch with 4-step flow (input → review → weighting → save). 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,96 @@
|
||||
import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
|
||||
import type { ParseNeedResult } from '../../domain/needBuilder'
|
||||
import { ExtractedFieldRow } from './ExtractedFieldRow'
|
||||
|
||||
interface Props {
|
||||
result: ParseNeedResult
|
||||
}
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro',
|
||||
LOGISTICS: 'Logistik / Lager',
|
||||
RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion',
|
||||
GASTRO: 'Gastro / F&B',
|
||||
}
|
||||
|
||||
export function CriteriaReviewPanel({ result }: Props) {
|
||||
const { extractedCriteria: c, confidenceByField: conf, missingFields, assumptions } = result
|
||||
|
||||
return (
|
||||
<Card sx={{ p: 3, height: '100%' }}>
|
||||
<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>
|
||||
|
||||
<ExtractedFieldRow
|
||||
label="Nutzungstyp"
|
||||
value={c.assetType ? (ASSET_LABELS[c.assetType] ?? c.assetType) : 'Nicht erkannt'}
|
||||
confidence={conf.assetType ?? 0.2}
|
||||
missing={!c.assetType}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Flächenbedarf"
|
||||
value={c.areaRange ? `${c.areaRange.min}–${c.areaRange.max} m²` : 'Nicht erkannt'}
|
||||
confidence={conf.areaRange ?? 0.2}
|
||||
missing={!c.areaRange}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Standort"
|
||||
value={c.preferredLocations && c.preferredLocations.length > 0 ? c.preferredLocations.join(', ') : 'Nicht erkannt'}
|
||||
confidence={conf.preferredLocations ?? 0.15}
|
||||
missing={!c.preferredLocations || c.preferredLocations.length === 0}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Budget"
|
||||
value={c.budgetRange ? `max. CHF ${c.budgetRange.maxPerSqm}/m²` : 'Nicht erkannt'}
|
||||
confidence={conf.budgetRange ?? 0.2}
|
||||
missing={!c.budgetRange}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Verfügbarkeit"
|
||||
value={c.timing ? `ab ${c.timing.earliestMoveIn}${c.timing.flexibleTiming ? ' (flexibel)' : ''}` : 'Nicht erkannt'}
|
||||
confidence={conf.timing ?? 0.2}
|
||||
missing={!c.timing}
|
||||
/>
|
||||
{c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
|
||||
<ExtractedFieldRow
|
||||
label="Must-haves"
|
||||
value={c.mustHaveCriteria.join(', ')}
|
||||
confidence={conf.mustHaveCriteria ?? 0.85}
|
||||
/>
|
||||
)}
|
||||
|
||||
{assumptions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
|
||||
Annahmen der KI
|
||||
</Typography>
|
||||
<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>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{missingFields.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
|
||||
Fehlende Angaben
|
||||
</Typography>
|
||||
<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>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string
|
||||
confidence: number
|
||||
missing?: boolean
|
||||
}
|
||||
|
||||
export function ExtractedFieldRow({ label, value, confidence, missing = false }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
py: 1,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
opacity: missing ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<ConfidenceFieldBadge confidence={confidence} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Box, Button, Card, Typography } from '@mui/material'
|
||||
import { ArrowRight } 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
|
||||
}
|
||||
|
||||
export function FollowUpPanel({ questions, answers, onAnswer, onContinue }: 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.
|
||||
</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' }}>
|
||||
{requiredUnanswered.length > 0 && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mb: 1 }}>
|
||||
{requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er' : ''} fehlt noch.
|
||||
</Typography>
|
||||
)}
|
||||
<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,31 @@
|
||||
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 = ['Bedarf eingeben', 'Kriterien prüfen', 'Gewichtung', 'Speichern']
|
||||
|
||||
function toStepIndex(step: NeedBuilderStep): number {
|
||||
if (step === S.IDLE || step === S.PARSING) return 0
|
||||
if (step === S.PARSED_REQUIRES_REVIEW || step === S.CLARIFICATION_REQUIRED) return 1
|
||||
if (step === S.WEIGHTING_REVIEW) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
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,116 @@
|
||||
import { Box, Card, Chip, Divider, LinearProgress, Stack, Typography } from '@mui/material'
|
||||
import { MapPin, Ruler, Wallet, Clock, CheckSquare } 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>
|
||||
}
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro',
|
||||
LOGISTICS: 'Logistik / Lager',
|
||||
RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion',
|
||||
GASTRO: 'Gastro / F&B',
|
||||
}
|
||||
|
||||
export function NeedCardPreview({ criteria: c, weights }: Props) {
|
||||
const maxWeight = Math.max(...WEIGHTING_KEYS.map(k => weights[k] ?? 0), 0.01)
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Vorschau — Neuer Bedarf
|
||||
</Typography>
|
||||
|
||||
<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="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 }} />
|
||||
|
||||
{/* 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: 120, 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,66 @@
|
||||
import { Box, Button, Card, Chip, TextField, Typography, Stack } from '@mui/material'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const EXAMPLES = [
|
||||
'Büro Zürich 500–800 m², ab September 2025, max. 45 CHF/m², gute ÖV-Anbindung',
|
||||
'Logistik Basel 2000 m², Tiefgarage, sofort verfügbar',
|
||||
'Retail Bern Innenstadt, hohe Passantenfrequenz, max. CHF 200/m²',
|
||||
]
|
||||
|
||||
export function NeedInput({ value, onChange, onSubmit }: Props) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
<Card sx={{ p: 3, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Flächenbedarf beschreiben
|
||||
</Typography>
|
||||
<TextField
|
||||
multiline
|
||||
rows={5}
|
||||
fullWidth
|
||||
placeholder="Beispiel: 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={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
slotProps={{ htmlInput: { maxLength: 2000 } }}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'right', mb: 2 }}>
|
||||
{value.length}/2000
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={value.length < 20}
|
||||
onClick={onSubmit}
|
||||
endIcon={<Sparkles size={16} />}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
KI-Analyse starten
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Beispiele:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
|
||||
{EXAMPLES.map(q => (
|
||||
<Chip
|
||||
key={q}
|
||||
label={q}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onChange(q)}
|
||||
sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function WeightingEditor({ weights, onChange, assetType }: Props) {
|
||||
const total = WEIGHTING_KEYS.reduce((sum, k) => sum + (weights[k] ?? 0), 0)
|
||||
const totalPct = Math.round(total * 100)
|
||||
const isBalanced = totalPct >= 95 && totalPct <= 105
|
||||
|
||||
function handleSlider(key: WeightingKey, pct: number) {
|
||||
onChange({ ...weights, [key]: pct / 100 })
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
onChange(weightingService.getDefaultWeights(assetType))
|
||||
}
|
||||
|
||||
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 }}>
|
||||
Kriteriengewichtung anpassen
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Passen Sie an, wie stark jedes Kriterium das Matching beeinflusst.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RotateCcw size={14} />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
||||
{WEIGHTING_KEYS.map(key => {
|
||||
const pct = Math.round((weights[key] ?? 0) * 100)
|
||||
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="body2" color="text.secondary">
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={pct}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
onChange={(_, v) => handleSlider(key, v as number)}
|
||||
size="small"
|
||||
sx={{ color: '#1e3a5f' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
pt: 2,
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gesamt
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: isBalanced ? '#1a7a4a' : '#c0392b' }}
|
||||
>
|
||||
{totalPct}%{!isBalanced && ' — Summe sollte ~100% ergeben'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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 { WeightingEditor } from './WeightingEditor'
|
||||
Reference in New Issue
Block a user