feat: role-aware UX, market intelligence, score fix & layout scroll
- Role-based workspace access: Property Manager gets Supply+Demand, Operations restricted to Super Admin + Reviewer only - Root redirect routes each persona to their first workspace - Match Center: replaced 3-panel with auto-sorted flat list + drawer - AI Search: unified form with dual-action (Jetzt suchen / Als Suchprofil speichern) - CompareTray hidden on non-Demand routes; Vergleichen removed from Supply - LocationIntelligencePanel: city KPIs, rent trends, soft factors, comparables - NegotiationInsightsPanel: price positioning, active demand, selling arguments - scoreCalculator: cap hardMatchScore and softFactorScore to max 100 - Layout: add display:flex to overflow:hidden wrappers so inner scroll works (MatchCenter drawer, MarketIntelligence, SourceMonitoring, SignalPipeline) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+236
-106
@@ -1,13 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/material'
|
||||
import { ArrowRight, ArrowLeft, Save } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { ArrowRight, Bookmark, Save, Search } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
NeedBuilderProgress,
|
||||
NeedInput,
|
||||
CriteriaReviewPanel,
|
||||
FollowUpPanel,
|
||||
VoiceNeedInput,
|
||||
WeightingEditor,
|
||||
NeedCardPreview,
|
||||
NeedBuilderErrorState,
|
||||
@@ -20,16 +26,34 @@ import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../do
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import type { CreateNeedInput } from '../../domain/need'
|
||||
|
||||
// ── Map ParsedNeedCriteria → CreateNeedInput ───────────────────────────────────
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ASSET_LABELS_TEXT: Record<string, string> = {
|
||||
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
|
||||
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
|
||||
}
|
||||
|
||||
function generateSummary(c: ParsedNeedCriteria): string {
|
||||
const parts: string[] = []
|
||||
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
|
||||
if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0))
|
||||
parts.push(`${c.areaRange.min}–${c.areaRange.max} m²`)
|
||||
if (c.preferredLocations?.length) parts.push(`in ${c.preferredLocations.join(', ')}`)
|
||||
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
|
||||
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
|
||||
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
function buildNeedInput(
|
||||
criteria: ParsedNeedCriteria,
|
||||
weights: Record<WeightingKey, number>,
|
||||
needTitle: string,
|
||||
overallConfidence: number,
|
||||
status: 'DRAFT' | 'ACTIVE',
|
||||
): CreateNeedInput {
|
||||
return {
|
||||
companyName: needTitle || criteria.companyName || 'Neuer Bedarf',
|
||||
companyName: needTitle || criteria.companyName || 'Neue Suche',
|
||||
assetType: criteria.assetType ?? AssetType.UNKNOWN,
|
||||
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
|
||||
preferredLocations: criteria.preferredLocations ?? [],
|
||||
@@ -42,77 +66,157 @@ function buildNeedInput(
|
||||
},
|
||||
weightingProfile: weights,
|
||||
confidenceInCriteria: overallConfidence,
|
||||
status: overallConfidence < 0.6 ? 'DRAFT' : 'ACTIVE',
|
||||
status,
|
||||
mustCriteriaText: criteria.mustHaveCriteria ?? [],
|
||||
notes: criteria.notes,
|
||||
extractedFromText: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action intent ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ActionIntent = 'search' | 'save-profile'
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AISearch() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
|
||||
const [intent, setIntent] = useState<ActionIntent>('search')
|
||||
const [inputText, setInputText] = useState('')
|
||||
const [isAutoGen, setIsAutoGen] = useState(false)
|
||||
const [criteria, setCriteria] = useState<ParsedNeedCriteria>({})
|
||||
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 [weightingKey, setWeightingKey] = useState(0)
|
||||
const [needTitle, setNeedTitle] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleAnalyze() {
|
||||
const isManualTextRef = useRef(false)
|
||||
|
||||
function handleCriteriaChange(next: ParsedNeedCriteria) {
|
||||
setCriteria(next)
|
||||
if (!isManualTextRef.current) {
|
||||
const summary = generateSummary(next)
|
||||
setInputText(summary)
|
||||
setIsAutoGen(!!summary)
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextChange(text: string) {
|
||||
isManualTextRef.current = text !== ''
|
||||
setIsAutoGen(false)
|
||||
setInputText(text)
|
||||
}
|
||||
|
||||
async function handleAiAutofill() {
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
setError(null)
|
||||
try {
|
||||
const resp = await aiService.parseNeed(inputText)
|
||||
const result = resp.data
|
||||
setParseResult(result)
|
||||
setEditedCriteria({ ...result.extractedCriteria })
|
||||
setCriteria({ ...result.extractedCriteria })
|
||||
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
setWeightingKey(k => k + 1)
|
||||
isManualTextRef.current = false
|
||||
setInputText('')
|
||||
setIsAutoGen(false)
|
||||
setStep(NeedBuilderStep.IDLE)
|
||||
} catch {
|
||||
setError('Die KI-Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.')
|
||||
setError('Die KI-Analyse ist fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReparse() {
|
||||
if (!editedCriteria) return
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
try {
|
||||
const resp = await aiService.generateFollowUpQuestions(editedCriteria)
|
||||
if (parseResult) {
|
||||
setParseResult({ ...parseResult, followUpQuestionCandidates: resp.data })
|
||||
// Resolve criteria (parse text if needed), then either search or show save preview
|
||||
async function handleAction(chosenIntent: ActionIntent) {
|
||||
setIntent(chosenIntent)
|
||||
setError(null)
|
||||
|
||||
let resolved: ParsedNeedCriteria = criteria
|
||||
let resolvedResult: ParseNeedResult | null = parseResult
|
||||
|
||||
if (!hasStructuredData && inputText.trim()) {
|
||||
setStep(NeedBuilderStep.PARSING)
|
||||
try {
|
||||
const resp = await aiService.parseNeed(inputText)
|
||||
resolved = resp.data.extractedCriteria
|
||||
resolvedResult = resp.data
|
||||
setCriteria(resolved)
|
||||
setWeights(resp.data.suggestedWeights as Record<WeightingKey, number>)
|
||||
setWeightingKey(k => k + 1)
|
||||
isManualTextRef.current = false
|
||||
setInputText('')
|
||||
setIsAutoGen(false)
|
||||
setParseResult(resp.data)
|
||||
} catch {
|
||||
setError('Die KI-Analyse ist fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
return
|
||||
}
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
} catch {
|
||||
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
|
||||
}
|
||||
|
||||
if (chosenIntent === 'search') {
|
||||
// Save as DRAFT and navigate immediately
|
||||
setStep(NeedBuilderStep.SAVING)
|
||||
try {
|
||||
const conf = resolvedResult
|
||||
? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) /
|
||||
Math.max(Object.values(resolvedResult.confidenceByField).length, 1)
|
||||
: 0.5
|
||||
const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT')
|
||||
const created = await needService.create(input)
|
||||
await queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
|
||||
} catch {
|
||||
setError('Suche fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// save-profile: show preview step
|
||||
const confidenceByField: Record<string, number> = resolvedResult?.confidenceByField ?? {}
|
||||
if (!resolvedResult) {
|
||||
if (resolved.assetType) confidenceByField.assetType = 1.0
|
||||
if (resolved.areaRange?.min) confidenceByField.areaRange = 1.0
|
||||
if (resolved.preferredLocations?.length) confidenceByField.preferredLocations = 1.0
|
||||
if (resolved.budgetRange?.maxPerSqm) confidenceByField.budgetRange = 1.0
|
||||
if (resolved.timing?.earliestMoveIn) confidenceByField.timing = 1.0
|
||||
}
|
||||
setEditedCriteria({ ...resolved })
|
||||
setParseResult(resolvedResult ?? {
|
||||
extractedCriteria: resolved,
|
||||
confidenceByField,
|
||||
missingFields: [],
|
||||
assumptions: [],
|
||||
suggestedWeights: weights,
|
||||
followUpQuestionCandidates: [],
|
||||
rawSummary: 'Manuell eingegeben',
|
||||
promptVersion: 'manual',
|
||||
schemaVersion: '1.0',
|
||||
})
|
||||
setStep(NeedBuilderStep.READY_TO_SAVE)
|
||||
}
|
||||
|
||||
function handleAnswer(id: string, ans: string) {
|
||||
setAnswers(prev => ({ ...prev, [id]: ans }))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
async function handleSaveProfile() {
|
||||
if (!editedCriteria || !parseResult) return
|
||||
setStep(NeedBuilderStep.SAVING)
|
||||
|
||||
const fieldEntries = Object.entries(parseResult.confidenceByField)
|
||||
const overallConfidence = fieldEntries.length > 0
|
||||
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
|
||||
: 0
|
||||
|
||||
const entries = Object.entries(parseResult.confidenceByField)
|
||||
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
||||
try {
|
||||
const input = buildNeedInput(editedCriteria, weights, needTitle, overallConfidence)
|
||||
await needService.create(input)
|
||||
setStep(NeedBuilderStep.SAVED)
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true } })
|
||||
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE')
|
||||
const created = await needService.create(input)
|
||||
await queryClient.invalidateQueries({ queryKey: ['needs'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
|
||||
} catch {
|
||||
setError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.')
|
||||
setError('Speichern fehlgeschlagen.')
|
||||
setStep(NeedBuilderStep.ERROR)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +228,13 @@ export default function AISearch() {
|
||||
setEditedCriteria(null)
|
||||
}
|
||||
|
||||
const isReview = step === NeedBuilderStep.PARSED_REQUIRES_REVIEW || step === NeedBuilderStep.CLARIFICATION_REQUIRED
|
||||
const hasStructuredData = !!(
|
||||
criteria.assetType ||
|
||||
(criteria.areaRange?.min ?? 0) > 0 ||
|
||||
(criteria.preferredLocations?.length ?? 0) > 0
|
||||
)
|
||||
const canProceed = hasStructuredData || inputText.trim().length > 0
|
||||
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
|
||||
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
|
||||
|
||||
const overallConfidence = parseResult
|
||||
@@ -136,82 +246,103 @@ export default function AISearch() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<PageHeader
|
||||
title="AI Bedarfsanalyse"
|
||||
subtitle="Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache"
|
||||
/>
|
||||
{/* Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Flächensuche</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Sprechen, schreiben oder Felder ausfüllen — dann sofort suchen oder als Suchprofil speichern
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<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} />
|
||||
)}
|
||||
{/* ── IDLE: full form ── */}
|
||||
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
|
||||
|
||||
{/* Step: Parsing */}
|
||||
{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>
|
||||
</Box>
|
||||
)}
|
||||
<VoiceNeedInput
|
||||
text={inputText}
|
||||
onTextChange={handleTextChange}
|
||||
isAutoGen={isAutoGen}
|
||||
onAiSubmit={handleAiAutofill}
|
||||
isAnalyzing={step === NeedBuilderStep.PARSING}
|
||||
/>
|
||||
|
||||
{/* Step: Criteria Review + Follow-up */}
|
||||
{isReview && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
|
||||
<CriteriaReviewPanel
|
||||
result={parseResult}
|
||||
criteria={editedCriteria}
|
||||
onCriteriaChange={setEditedCriteria}
|
||||
/>
|
||||
<FollowUpPanel
|
||||
questions={parseResult.followUpQuestionCandidates}
|
||||
answers={answers}
|
||||
onAnswer={handleAnswer}
|
||||
onContinue={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
|
||||
onReparse={handleReparse}
|
||||
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
|
||||
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
|
||||
<WeightingEditor
|
||||
key={weightingKey}
|
||||
weights={weights}
|
||||
onChange={setWeights}
|
||||
assetType={criteria.assetType}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
>
|
||||
Neu eingeben
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Weighting */}
|
||||
{step === NeedBuilderStep.WEIGHTING_REVIEW && (
|
||||
<Box>
|
||||
<WeightingEditor
|
||||
weights={weights}
|
||||
onChange={setWeights}
|
||||
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)}>
|
||||
Zurück
|
||||
</Button>
|
||||
{/* Action bar */}
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={<ArrowRight size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.READY_TO_SAVE)}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
size="large"
|
||||
disabled={!canProceed || isProcessing}
|
||||
onClick={() => handleAction('search')}
|
||||
endIcon={
|
||||
isProcessing && intent === 'search'
|
||||
? <CircularProgress size={18} color="inherit" />
|
||||
: <Search size={18} />
|
||||
}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
Vorschau & Speichern
|
||||
{isProcessing && intent === 'search' ? 'Sucht…' : 'Jetzt suchen'}
|
||||
</Button>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
disabled={!canProceed || isProcessing}
|
||||
onClick={() => handleAction('save-profile')}
|
||||
startIcon={<Bookmark size={16} />}
|
||||
endIcon={
|
||||
isProcessing && intent === 'save-profile'
|
||||
? <CircularProgress size={18} color="inherit" />
|
||||
: <ArrowRight size={18} />
|
||||
}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isProcessing && intent === 'save-profile' ? 'Analysiert…' : 'Als Suchprofil speichern'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mt: -1 }}>
|
||||
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
|
||||
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Preview + Save */}
|
||||
{/* ── Preview + Save as Profile ── */}
|
||||
{isSaveStep && parseResult && editedCriteria && (
|
||||
<Box>
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
|
||||
</Alert>
|
||||
<NeedCardPreview
|
||||
criteria={editedCriteria}
|
||||
weights={weights}
|
||||
@@ -223,34 +354,33 @@ export default function AISearch() {
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
|
||||
onClick={() => setStep(NeedBuilderStep.IDLE)}
|
||||
disabled={step === NeedBuilderStep.SAVING}
|
||||
>
|
||||
Zurück
|
||||
← Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
onClick={handleSaveProfile}
|
||||
disabled={step === NeedBuilderStep.SAVING}
|
||||
endIcon={
|
||||
step === NeedBuilderStep.SAVING
|
||||
? <CircularProgress size={16} color="inherit" />
|
||||
: <Save size={16} />
|
||||
}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
|
||||
>
|
||||
{step === NeedBuilderStep.SAVING
|
||||
? 'Wird gespeichert…'
|
||||
: overallConfidence < 0.6
|
||||
? 'Als Entwurf speichern'
|
||||
: 'Bedarf speichern & Suche starten'}
|
||||
: 'Suchprofil speichern & Matching starten'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Step: Error */}
|
||||
{/* ── Error ── */}
|
||||
{step === NeedBuilderStep.ERROR && (
|
||||
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user