5035445d2e
All page-level headers now use fontSize:1.125rem, fontWeight:700, color:#0f172a, container py:2.5, borderBottom:#e8e7e4 — matching the standard established in PageHeader. Removes variant="h5"/h6" inconsistencies across Supply, Demand, and shared components. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { useRef, useState } from 'react'
|
|
import { Box, Typography } from '@mui/material'
|
|
import { useNavigate } from 'react-router'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
NeedBuilderProgress,
|
|
NeedInput,
|
|
VoiceNeedInput,
|
|
WeightingEditor,
|
|
NeedBuilderErrorState,
|
|
} from '../../components/demand'
|
|
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
|
|
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
|
|
import { useParseNeed } from '../../hooks/useAI'
|
|
import { useCreateNeed } from '../../hooks/useNeeds'
|
|
import { useDefaultWeights } from '../../hooks/useWeighting'
|
|
import { NeedBuilderStep } from '../../domain/needBuilder'
|
|
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
|
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
|
|
|
|
type ActionIntent = 'search' | 'save-profile'
|
|
|
|
export default function AISearch() {
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
|
|
const parseNeedMutation = useParseNeed()
|
|
const createNeedMutation = useCreateNeed()
|
|
const defaultWeights = useDefaultWeights()
|
|
|
|
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 [weights, setWeights] = useState<Record<WeightingKey, number>>(defaultWeights)
|
|
const [weightingKey, setWeightingKey] = useState(0)
|
|
const [needTitle, setNeedTitle] = useState('')
|
|
const [isAnonymous, setIsAnonymous] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
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)
|
|
// Clear stale parse results when user edits the text manually
|
|
setCriteria({})
|
|
setParseResult(null)
|
|
}
|
|
|
|
function handleAiAutofill() {
|
|
setStep(NeedBuilderStep.PARSING)
|
|
setError(null)
|
|
parseNeedMutation.mutate(inputText, {
|
|
onSuccess: (resp) => {
|
|
const result = resp.data
|
|
setParseResult(result)
|
|
setCriteria({ ...result.extractedCriteria })
|
|
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
|
|
setWeightingKey(k => k + 1)
|
|
isManualTextRef.current = false
|
|
setInputText('')
|
|
setIsAutoGen(false)
|
|
setStep(NeedBuilderStep.IDLE)
|
|
},
|
|
onError: () => {
|
|
setError('Die KI-Analyse ist fehlgeschlagen.')
|
|
setStep(NeedBuilderStep.ERROR)
|
|
},
|
|
})
|
|
}
|
|
|
|
function handleAction(chosenIntent: ActionIntent) {
|
|
setIntent(chosenIntent)
|
|
setError(null)
|
|
|
|
const resolved: ParsedNeedCriteria = criteria
|
|
const resolvedResult: ParseNeedResult | null = parseResult
|
|
|
|
if (!hasStructuredData && inputText.trim()) {
|
|
setStep(NeedBuilderStep.PARSING)
|
|
parseNeedMutation.mutate(inputText, {
|
|
onSuccess: (resp) => {
|
|
const parsedResolved = resp.data.extractedCriteria
|
|
const parsedResult = resp.data
|
|
setCriteria(parsedResolved)
|
|
setWeights(resp.data.suggestedWeights as Record<WeightingKey, number>)
|
|
setWeightingKey(k => k + 1)
|
|
isManualTextRef.current = false
|
|
setInputText('')
|
|
setIsAutoGen(false)
|
|
setParseResult(resp.data)
|
|
continueAction(chosenIntent, parsedResolved, parsedResult)
|
|
},
|
|
onError: () => {
|
|
setError('Die KI-Analyse ist fehlgeschlagen.')
|
|
setStep(NeedBuilderStep.ERROR)
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
continueAction(chosenIntent, resolved, resolvedResult)
|
|
}
|
|
|
|
function continueAction(
|
|
chosenIntent: ActionIntent,
|
|
resolved: ParsedNeedCriteria,
|
|
resolvedResult: ParseNeedResult | null,
|
|
) {
|
|
if (chosenIntent === 'search') {
|
|
setStep(NeedBuilderStep.SAVING)
|
|
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')
|
|
createNeedMutation.mutate(input, {
|
|
onSuccess: (created) => {
|
|
queryClient.invalidateQueries({ queryKey: ['matches'] })
|
|
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
|
|
},
|
|
onError: () => {
|
|
setError('Suche fehlgeschlagen.')
|
|
setStep(NeedBuilderStep.ERROR)
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
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 handleSaveProfile() {
|
|
if (!editedCriteria || !parseResult) return
|
|
setStep(NeedBuilderStep.SAVING)
|
|
const entries = Object.entries(parseResult.confidenceByField)
|
|
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
|
const input = buildNeedInput({ ...editedCriteria, isAnonymous: isAnonymous || undefined }, weights, needTitle, conf, 'ACTIVE')
|
|
createNeedMutation.mutate(input, {
|
|
onSuccess: (created) => {
|
|
queryClient.invalidateQueries({ queryKey: ['matches'] })
|
|
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
|
|
},
|
|
onError: () => {
|
|
setError('Speichern fehlgeschlagen.')
|
|
setStep(NeedBuilderStep.ERROR)
|
|
},
|
|
})
|
|
}
|
|
|
|
function handleRetry() {
|
|
setStep(NeedBuilderStep.IDLE)
|
|
setError(null)
|
|
setParseResult(null)
|
|
setEditedCriteria(null)
|
|
}
|
|
|
|
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
|
|
? (() => {
|
|
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' }}>
|
|
{/* Header */}
|
|
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
|
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>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 }}>
|
|
|
|
{/* IDLE: full form */}
|
|
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
|
|
<VoiceNeedInput
|
|
text={inputText}
|
|
onTextChange={handleTextChange}
|
|
isAutoGen={isAutoGen}
|
|
onAiSubmit={handleAiAutofill}
|
|
isAnalyzing={step === NeedBuilderStep.PARSING}
|
|
/>
|
|
<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>
|
|
<AISearchActionBar
|
|
canProceed={canProceed}
|
|
isSearching={isProcessing && intent === 'search'}
|
|
isSavingProfile={isProcessing && intent === 'save-profile'}
|
|
onSearch={() => handleAction('search')}
|
|
onSaveProfile={() => handleAction('save-profile')}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Save preview step */}
|
|
{isSaveStep && parseResult && editedCriteria && (
|
|
<AISearchSavePreview
|
|
criteria={editedCriteria}
|
|
weights={weights}
|
|
parseResult={parseResult}
|
|
needTitle={needTitle}
|
|
overallConfidence={overallConfidence}
|
|
isSaving={step === NeedBuilderStep.SAVING}
|
|
isAnonymous={isAnonymous}
|
|
onNeedTitleChange={setNeedTitle}
|
|
onAnonymousChange={setIsAnonymous}
|
|
onBack={() => setStep(NeedBuilderStep.IDLE)}
|
|
onSave={handleSaveProfile}
|
|
/>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{step === NeedBuilderStep.ERROR && (
|
|
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|