feat: F008 AI need builder — full spec compliance (editable criteria, grouped sections, needService.create)
- NeedInput: Reset button, Asset-Type Quick Select chips, 3 typed examples - ExtractedFieldRow: inline edit mode (pencil → TextField, Enter/blur commits) - CriteriaReviewPanel: 3 grouped sections (Hard Facts, Soft Factors, Must-haves) + AI Assumptions + Missing Information; all 14 criteria fields shown and editable; onCriteriaChange propagates typed edits to parent - FollowUpPanel: Rückfragen neu generieren button triggers onReparse - NeedCardPreview: Need Title input, Confidence Summary with progress bar, Missing Critical Fields alert, low-confidence draft label - AISearch page: editedCriteria state (mutable copy of parse result), handleReparse via generateFollowUpQuestions, handleSave calls needService.create() with full CreateNeedInput mapping, low-confidence → status DRAFT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,70 @@
|
||||
import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
|
||||
import type { ParseNeedResult } from '../../domain/needBuilder'
|
||||
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',
|
||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||
}
|
||||
|
||||
export function CriteriaReviewPanel({ result }: Props) {
|
||||
const { extractedCriteria: c, confidenceByField: conf, missingFields, assumptions } = result
|
||||
// ── 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%' }}>
|
||||
<Card sx={{ p: 3, height: '100%', overflowY: 'auto' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Extrahierte Kriterien
|
||||
</Typography>
|
||||
@@ -26,49 +72,119 @@ export function CriteriaReviewPanel({ result }: Props) {
|
||||
{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 && (
|
||||
{/* ── Hard Facts ──────────────────────────────────────────────────── */}
|
||||
<Section title="Hard Facts">
|
||||
<ExtractedFieldRow
|
||||
label="Must-haves"
|
||||
value={c.mustHaveCriteria.join(', ')}
|
||||
confidence={conf.mustHaveCriteria ?? 0.85}
|
||||
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>
|
||||
|
||||
{/* ── Soft Factors ────────────────────────────────────────────────── */}
|
||||
<Section title="Soft Factors">
|
||||
<ExtractedFieldRow
|
||||
label="Prestige-Anforderung"
|
||||
value={c.prestigeImportance ?? ''}
|
||||
confidence={conf.prestigeImportance ?? 0.2}
|
||||
missing={!c.prestigeImportance}
|
||||
onEdit={v => set({ ...c, prestigeImportance: (v.toUpperCase() as ParsedNeedCriteria['prestigeImportance']) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Flexibilitätsbedarf"
|
||||
value={c.flexibilityNeed ?? ''}
|
||||
confidence={0.5}
|
||||
missing={!c.flexibilityNeed}
|
||||
onEdit={v => set({ ...c, flexibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['flexibilityNeed']) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Expansionspotenzial"
|
||||
value={c.expansionPotential === true ? 'Ja' : c.expansionPotential === false ? 'Nein' : ''}
|
||||
confidence={0.5}
|
||||
missing={c.expansionPotential === undefined}
|
||||
onEdit={v => set({ ...c, expansionPotential: v.toLowerCase().startsWith('j') })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Parkplatzbedarf"
|
||||
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
|
||||
confidence={conf.parkingNeed ?? 0.3}
|
||||
missing={c.parkingNeed === undefined}
|
||||
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Sichtbarkeit"
|
||||
value={c.visibilityNeed ?? ''}
|
||||
confidence={0.4}
|
||||
missing={!c.visibilityNeed}
|
||||
onEdit={v => set({ ...c, visibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['visibilityNeed']) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Passantenfrequenz"
|
||||
value={c.footfallNeed ?? ''}
|
||||
confidence={0.4}
|
||||
missing={!c.footfallNeed}
|
||||
onEdit={v => set({ ...c, footfallNeed: (v.toUpperCase() as ParsedNeedCriteria['footfallNeed']) })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── Must-haves ──────────────────────────────────────────────────── */}
|
||||
<Section title="Must-haves & Infrastruktur">
|
||||
<ExtractedFieldRow
|
||||
label="Must-have Kriterien (Komma-getrennt)"
|
||||
value={c.mustHaveCriteria?.join(', ') ?? ''}
|
||||
confidence={conf.mustHaveCriteria ?? 0.1}
|
||||
missing={!c.mustHaveCriteria?.length}
|
||||
onEdit={v => set({ ...c, mustHaveCriteria: parseList(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Infrastrukturanforderungen (Komma-getrennt)"
|
||||
value={c.infrastructureRequirements?.join(', ') ?? ''}
|
||||
confidence={0.5}
|
||||
missing={!c.infrastructureRequirements?.length}
|
||||
onEdit={v => set({ ...c, infrastructureRequirements: parseList(v) })}
|
||||
/>
|
||||
<ExtractedFieldRow
|
||||
label="Barrierefreiheit (Komma-getrennt)"
|
||||
value={c.accessibilityRequirements?.join(', ') ?? ''}
|
||||
confidence={0.5}
|
||||
missing={!c.accessibilityRequirements?.length}
|
||||
onEdit={v => set({ ...c, accessibilityRequirements: parseList(v) })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── AI Assumptions ──────────────────────────────────────────────── */}
|
||||
{assumptions.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
|
||||
Annahmen der KI
|
||||
</Typography>
|
||||
<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 } }}>
|
||||
@@ -76,20 +192,18 @@ export function CriteriaReviewPanel({ result }: Props) {
|
||||
</Alert>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Missing Information ─────────────────────────────────────────── */}
|
||||
{missingFields.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
|
||||
Fehlende Angaben
|
||||
</Typography>
|
||||
<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>
|
||||
</Box>
|
||||
</Section>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { Box, IconButton, TextField, Typography } from '@mui/material'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
|
||||
|
||||
interface Props {
|
||||
@@ -6,9 +8,37 @@ interface Props {
|
||||
value: string
|
||||
confidence: number
|
||||
missing?: boolean
|
||||
onEdit?: (value: string) => void
|
||||
}
|
||||
|
||||
export function ExtractedFieldRow({ label, value, confidence, missing = false }: Props) {
|
||||
export function ExtractedFieldRow({ label, value, confidence, 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={{
|
||||
@@ -19,6 +49,7 @@ export function ExtractedFieldRow({ label, value, confidence, missing = false }:
|
||||
py: 1,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
opacity: missing ? 0.55 : 1,
|
||||
'&:hover .edit-btn': { visibility: 'visible' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
@@ -26,10 +57,22 @@ export function ExtractedFieldRow({ label, value, confidence, missing = false }:
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontStyle: missing ? 'italic' : 'normal' }}>
|
||||
{value}
|
||||
{value || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ConfidenceFieldBadge confidence={confidence} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
<ConfidenceFieldBadge confidence={confidence} />
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
size="small"
|
||||
className="edit-btn"
|
||||
sx={{ visibility: 'hidden', p: 0.25 }}
|
||||
onClick={() => { setEditValue(value); setEditing(true) }}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, Card, Typography } from '@mui/material'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { ArrowRight, RefreshCw } from 'lucide-react'
|
||||
import type { FollowUpQuestion } from '../../domain/needBuilder'
|
||||
import { FollowUpQuestionCard } from './FollowUpQuestionCard'
|
||||
|
||||
@@ -8,9 +8,10 @@ interface Props {
|
||||
answers: Record<string, string>
|
||||
onAnswer: (id: string, answer: string) => void
|
||||
onContinue: () => void
|
||||
onReparse?: () => void
|
||||
}
|
||||
|
||||
export function FollowUpPanel({ questions, answers, onAnswer, onContinue }: Props) {
|
||||
export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onReparse }: Props) {
|
||||
const requiredUnanswered = questions
|
||||
.filter(q => q.importance === 'required')
|
||||
.filter(q => !answers[q.id])
|
||||
@@ -27,7 +28,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue }: Prop
|
||||
Rückfragen der KI
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Beantworten Sie die Pflichtfelder für optimale Ergebnisse.
|
||||
Beantworten Sie die Pflichtfelder für optimale Ergebnisse. Optionale Fragen können übersprungen werden.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
@@ -43,12 +44,22 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue }: Prop
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ pt: 2, mt: 'auto' }}>
|
||||
<Box sx={{ pt: 2, mt: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{requiredUnanswered.length > 0 && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mb: 1 }}>
|
||||
{requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er' : ''} fehlt noch.
|
||||
<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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Card, Chip, Divider, LinearProgress, Stack, Typography } from '@mui/material'
|
||||
import { MapPin, Ruler, Wallet, Clock, CheckSquare } from 'lucide-react'
|
||||
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'
|
||||
@@ -7,32 +7,68 @@ 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',
|
||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
||||
}
|
||||
|
||||
export function NeedCardPreview({ criteria: c, weights }: Props) {
|
||||
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="ENTWURF" size="small" variant="outlined" />
|
||||
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 2 }}>
|
||||
@@ -90,6 +126,38 @@ export function NeedCardPreview({ criteria: c, weights }: Props) {
|
||||
|
||||
<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
|
||||
@@ -99,7 +167,7 @@ export function NeedCardPreview({ criteria: c, weights }: Props) {
|
||||
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>
|
||||
<Typography variant="caption" sx={{ width: 130, flexShrink: 0 }}>{WEIGHTING_LABELS[k]}</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={((weights[k] ?? 0) / maxWeight) * 100}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Button, Card, Chip, TextField, Typography, Stack } from '@mui/material'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { Sparkles, RotateCcw } from 'lucide-react'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
@@ -7,19 +8,31 @@ interface Props {
|
||||
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²',
|
||||
const EXAMPLES: Record<string, string> = {
|
||||
Büro: '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.',
|
||||
Retail: 'Suche Ladenfläche 200–400 m² in Bern Innenstadt, hohe Passantenfrequenz, Erdgeschoss, max. CHF 150/m². Sofort verfügbar.',
|
||||
Logistik: 'Lagerhalle 2.000–3.000 m² Basel Umgebung, Tiefgarage oder Aussenrampe, 12 m Deckenhöhe, sofort. Budget CHF 15/m².',
|
||||
}
|
||||
|
||||
const ASSET_TYPE_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
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 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Flächenbedarf beschreiben
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
Beschreiben Sie Typ, Fläche, Standort, Budget und Verfügbarkeit — die KI extrahiert die Kriterien automatisch.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
multiline
|
||||
rows={5}
|
||||
@@ -33,31 +46,60 @@ export function NeedInput({ value, onChange, onSubmit }: Props) {
|
||||
<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>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
disabled={value.length < 20}
|
||||
onClick={onSubmit}
|
||||
endIcon={<Sparkles size={16} />}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
KI-Analyse starten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => onChange('')}
|
||||
startIcon={<RotateCcw size={16} />}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Beispiele:
|
||||
{/* Asset-Type Quick Select */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Schnellauswahl Nutzungstyp:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
|
||||
{EXAMPLES.map(q => (
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
|
||||
{ASSET_TYPE_OPTIONS.map(opt => (
|
||||
<Chip
|
||||
key={q}
|
||||
label={q}
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onChange(q)}
|
||||
sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
onClick={() => onChange(EXAMPLES[opt.label] ?? value)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Example prompts */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Beispiele:
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{Object.entries(EXAMPLES).map(([label, text]) => (
|
||||
<Chip
|
||||
key={label}
|
||||
label={`${label}: ${text.slice(0, 60)}…`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
clickable
|
||||
onClick={() => onChange(text)}
|
||||
sx={{ height: 'auto', py: 0.5, '& .MuiChip-label': { whiteSpace: 'normal' } }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
+113
-44
@@ -13,18 +13,54 @@ import {
|
||||
NeedBuilderErrorState,
|
||||
} from '../../components/demand'
|
||||
import { aiService } from '../../services/aiService'
|
||||
import { needService } from '../../services/needService'
|
||||
import { weightingService } from '../../services/weightingService'
|
||||
import { NeedBuilderStep } from '../../domain/needBuilder'
|
||||
import type { ParseNeedResult } from '../../domain/needBuilder'
|
||||
import type { WeightingKey } from '../../domain/needBuilder'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import type { CreateNeedInput } from '../../domain/need'
|
||||
|
||||
// ── Map ParsedNeedCriteria → CreateNeedInput ───────────────────────────────────
|
||||
|
||||
function buildNeedInput(
|
||||
criteria: ParsedNeedCriteria,
|
||||
weights: Record<WeightingKey, number>,
|
||||
needTitle: string,
|
||||
overallConfidence: number,
|
||||
): CreateNeedInput {
|
||||
return {
|
||||
companyName: needTitle || criteria.companyName || 'Neuer Bedarf',
|
||||
assetType: criteria.assetType ?? AssetType.UNKNOWN,
|
||||
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
|
||||
preferredLocations: criteria.preferredLocations ?? [],
|
||||
budgetRange: criteria.budgetRange ?? { maxPerSqm: 0, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: criteria.timing?.earliestMoveIn ?? '',
|
||||
latestMoveIn: criteria.timing?.latestMoveIn ?? criteria.timing?.earliestMoveIn ?? '',
|
||||
contractDurationMonths: criteria.timing?.contractDurationMonths,
|
||||
flexibleTiming: criteria.timing?.flexibleTiming ?? true,
|
||||
},
|
||||
weightingProfile: weights,
|
||||
confidenceInCriteria: overallConfidence,
|
||||
status: overallConfidence < 0.6 ? 'DRAFT' : 'ACTIVE',
|
||||
mustCriteriaText: criteria.mustHaveCriteria ?? [],
|
||||
notes: criteria.notes,
|
||||
extractedFromText: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AISearch() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
|
||||
const [editedCriteria, setEditedCriteria] = useState<ParsedNeedCriteria | null>(null)
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({})
|
||||
const [weights, setWeights] = useState<Record<WeightingKey, number>>(weightingService.getDefaultWeights())
|
||||
const [needTitle, setNeedTitle] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleAnalyze() {
|
||||
@@ -34,6 +70,7 @@ export default function AISearch() {
|
||||
const resp = await aiService.parseNeed(inputText)
|
||||
const result = resp.data
|
||||
setParseResult(result)
|
||||
setEditedCriteria({ ...result.extractedCriteria })
|
||||
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
} catch {
|
||||
@@ -42,33 +79,61 @@ export default function AISearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReparse() {
|
||||
if (!editedCriteria) return
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
try {
|
||||
const resp = await aiService.generateFollowUpQuestions(editedCriteria)
|
||||
if (parseResult) {
|
||||
setParseResult({ ...parseResult, followUpQuestionCandidates: resp.data })
|
||||
}
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
} catch {
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAnswer(id: string, ans: string) {
|
||||
setAnswers(prev => ({ ...prev, [id]: ans }))
|
||||
}
|
||||
|
||||
function handleContinueToWeighting() {
|
||||
setStep(NeedBuilderStep.WEIGHTING_REVIEW)
|
||||
}
|
||||
|
||||
function handleContinueToSave() {
|
||||
setStep(NeedBuilderStep.READY_TO_SAVE)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editedCriteria || !parseResult) return
|
||||
setStep(NeedBuilderStep.SAVING)
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true } })
|
||||
|
||||
const fieldEntries = Object.entries(parseResult.confidenceByField)
|
||||
const overallConfidence = fieldEntries.length > 0
|
||||
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
|
||||
: 0
|
||||
|
||||
try {
|
||||
const input = buildNeedInput(editedCriteria, weights, needTitle, overallConfidence)
|
||||
await needService.create(input)
|
||||
setStep(NeedBuilderStep.SAVED)
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true } })
|
||||
} catch {
|
||||
setError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
setStep(NeedBuilderStep.IDLE)
|
||||
setError(null)
|
||||
setParseResult(null)
|
||||
setEditedCriteria(null)
|
||||
}
|
||||
|
||||
const isReview = step === NeedBuilderStep.PARSED_REQUIRES_REVIEW || step === NeedBuilderStep.CLARIFICATION_REQUIRED
|
||||
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
|
||||
|
||||
const overallConfidence = parseResult
|
||||
? (() => {
|
||||
const entries = Object.entries(parseResult.confidenceByField)
|
||||
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
||||
})()
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<PageHeader
|
||||
@@ -78,6 +143,7 @@ export default function AISearch() {
|
||||
<NeedBuilderProgress step={step} />
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
||||
|
||||
{/* Step: Input */}
|
||||
{step === NeedBuilderStep.IDLE && (
|
||||
<NeedInput value={inputText} onChange={setInputText} onSubmit={handleAnalyze} />
|
||||
@@ -87,36 +153,35 @@ export default function AISearch() {
|
||||
{step === NeedBuilderStep.PARSING && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 16, gap: 3 }}>
|
||||
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
KI analysiert Ihren Bedarf…
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Kriterien werden extrahiert und bewertet
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">KI analysiert Ihren Bedarf…</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Kriterien werden extrahiert und bewertet</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Criteria Review + Follow-up */}
|
||||
{isReview && parseResult && (
|
||||
{isReview && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
|
||||
<CriteriaReviewPanel result={parseResult} />
|
||||
<CriteriaReviewPanel
|
||||
result={parseResult}
|
||||
criteria={editedCriteria}
|
||||
onCriteriaChange={setEditedCriteria}
|
||||
/>
|
||||
<FollowUpPanel
|
||||
questions={parseResult.followUpQuestionCandidates}
|
||||
answers={answers}
|
||||
onAnswer={handleAnswer}
|
||||
onContinue={handleContinueToWeighting}
|
||||
onContinue={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
|
||||
onReparse={handleReparse}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
>
|
||||
Neu eingeben
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
>
|
||||
Neu eingeben
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -126,20 +191,16 @@ export default function AISearch() {
|
||||
<WeightingEditor
|
||||
weights={weights}
|
||||
onChange={setWeights}
|
||||
assetType={parseResult?.extractedCriteria.assetType}
|
||||
assetType={editedCriteria?.assetType}
|
||||
/>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)}
|
||||
>
|
||||
<Button variant="outlined" startIcon={<ArrowLeft size={16} />} onClick={() => setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={<ArrowRight size={16} />}
|
||||
onClick={handleContinueToSave}
|
||||
onClick={() => setStep(NeedBuilderStep.READY_TO_SAVE)}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
Vorschau & Speichern
|
||||
@@ -149,9 +210,16 @@ export default function AISearch() {
|
||||
)}
|
||||
|
||||
{/* Step: Preview + Save */}
|
||||
{isSaveStep && parseResult && (
|
||||
{isSaveStep && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<NeedCardPreview criteria={parseResult.extractedCriteria} weights={weights} />
|
||||
<NeedCardPreview
|
||||
criteria={editedCriteria}
|
||||
weights={weights}
|
||||
confidenceByField={parseResult.confidenceByField}
|
||||
missingFields={parseResult.missingFields}
|
||||
needTitle={needTitle}
|
||||
onNeedTitleChange={setNeedTitle}
|
||||
/>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -172,7 +240,11 @@ export default function AISearch() {
|
||||
}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
{step === NeedBuilderStep.SAVING ? 'Wird gespeichert…' : 'Bedarf speichern & Suche starten'}
|
||||
{step === NeedBuilderStep.SAVING
|
||||
? 'Wird gespeichert…'
|
||||
: overallConfidence < 0.6
|
||||
? 'Als Entwurf speichern'
|
||||
: 'Bedarf speichern & Suche starten'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -180,10 +252,7 @@ export default function AISearch() {
|
||||
|
||||
{/* Step: Error */}
|
||||
{step === NeedBuilderStep.ERROR && (
|
||||
<NeedBuilderErrorState
|
||||
message={error ?? 'Unbekannter Fehler'}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user