diff --git a/src/components/demand/CriteriaReviewPanel.tsx b/src/components/demand/CriteriaReviewPanel.tsx index 97ca855..9cb63a8 100644 --- a/src/components/demand/CriteriaReviewPanel.tsx +++ b/src/components/demand/CriteriaReviewPanel.tsx @@ -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 = { - 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 ( + + + {title} + + {children} + + ) +} + +// ── Main component ───────────────────────────────────────────────────────────── + +export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set }: Props) { + const { confidenceByField: conf, missingFields, assumptions } = result return ( - + Extrahierte Kriterien @@ -26,49 +72,119 @@ export function CriteriaReviewPanel({ result }: Props) { {result.rawSummary} - - - 0 ? c.preferredLocations.join(', ') : 'Nicht erkannt'} - confidence={conf.preferredLocations ?? 0.15} - missing={!c.preferredLocations || c.preferredLocations.length === 0} - /> - - - {c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && ( + {/* ── Hard Facts ──────────────────────────────────────────────────── */} +
set({ ...c, assetType: (v.toUpperCase() as ParsedNeedCriteria['assetType']) })} /> - )} + set({ ...c, areaRange: parseAreaRange(v) })} + /> + set({ ...c, preferredLocations: parseList(v) })} + /> + set({ ...c, budgetRange: parseBudget(v) })} + /> + set({ ...c, timing: { earliestMoveIn: v, latestMoveIn: v, flexibleTiming: v.toLowerCase().includes('flex') } })} + /> +
+ {/* ── Soft Factors ────────────────────────────────────────────────── */} +
+ set({ ...c, prestigeImportance: (v.toUpperCase() as ParsedNeedCriteria['prestigeImportance']) })} + /> + set({ ...c, flexibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['flexibilityNeed']) })} + /> + set({ ...c, expansionPotential: v.toLowerCase().startsWith('j') })} + /> + set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })} + /> + set({ ...c, visibilityNeed: (v.toUpperCase() as ParsedNeedCriteria['visibilityNeed']) })} + /> + set({ ...c, footfallNeed: (v.toUpperCase() as ParsedNeedCriteria['footfallNeed']) })} + /> +
+ + {/* ── Must-haves ──────────────────────────────────────────────────── */} +
+ set({ ...c, mustHaveCriteria: parseList(v) })} + /> + set({ ...c, infrastructureRequirements: parseList(v) })} + /> + set({ ...c, accessibilityRequirements: parseList(v) })} + /> +
+ + {/* ── AI Assumptions ──────────────────────────────────────────────── */} {assumptions.length > 0 && ( - - - Annahmen der KI - +
{assumptions.map((a, i) => ( @@ -76,20 +192,18 @@ export function CriteriaReviewPanel({ result }: Props) { ))} - +
)} + {/* ── Missing Information ─────────────────────────────────────────── */} {missingFields.length > 0 && ( - - - Fehlende Angaben - +
{missingFields.map(f => ( ))} - +
)}
) diff --git a/src/components/demand/ExtractedFieldRow.tsx b/src/components/demand/ExtractedFieldRow.tsx index 16c4da0..955b5ad 100644 --- a/src/components/demand/ExtractedFieldRow.tsx +++ b/src/components/demand/ExtractedFieldRow.tsx @@ -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 ( + + + {label} + + setEditValue(e.target.value)} + onBlur={commit} + onKeyDown={e => { if (e.key === 'Enter') commit() }} + /> + + ) + } + return ( @@ -26,10 +57,22 @@ export function ExtractedFieldRow({ label, value, confidence, missing = false }: {label} - {value} + {value || '—'} - + + + {onEdit && ( + { setEditValue(value); setEditing(true) }} + > + + + )} + ) } diff --git a/src/components/demand/FollowUpPanel.tsx b/src/components/demand/FollowUpPanel.tsx index 7ab378e..0f86fb6 100644 --- a/src/components/demand/FollowUpPanel.tsx +++ b/src/components/demand/FollowUpPanel.tsx @@ -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 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 - Beantworten Sie die Pflichtfelder für optimale Ergebnisse. + Beantworten Sie die Pflichtfelder für optimale Ergebnisse. Optionale Fragen können übersprungen werden. @@ -43,12 +44,22 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue }: Prop - + {requiredUnanswered.length > 0 && ( - - {requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er' : ''} fehlt noch. + + {requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er fehlen' : ' fehlt'} noch. )} + {onReparse && ( + + )} + + + + +
- - Beispiele: + {/* Asset-Type Quick Select */} + + Schnellauswahl Nutzungstyp: - - {EXAMPLES.map(q => ( + + {ASSET_TYPE_OPTIONS.map(opt => ( onChange(q)} - sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }} + onClick={() => onChange(EXAMPLES[opt.label] ?? value)} + /> + ))} + + + {/* Example prompts */} + + Beispiele: + + + {Object.entries(EXAMPLES).map(([label, text]) => ( + onChange(text)} + sx={{ height: 'auto', py: 0.5, '& .MuiChip-label': { whiteSpace: 'normal' } }} /> ))} diff --git a/src/pages/demand/AISearch.tsx b/src/pages/demand/AISearch.tsx index dd19ec0..de56f27 100644 --- a/src/pages/demand/AISearch.tsx +++ b/src/pages/demand/AISearch.tsx @@ -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, + 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(NeedBuilderStep.IDLE) const [inputText, setInputText] = useState('') const [parseResult, setParseResult] = useState(null) + const [editedCriteria, setEditedCriteria] = useState(null) const [answers, setAnswers] = useState>({}) const [weights, setWeights] = useState>(weightingService.getDefaultWeights()) + const [needTitle, setNeedTitle] = useState('') const [error, setError] = useState(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) 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 ( + {/* Step: Input */} {step === NeedBuilderStep.IDLE && ( @@ -87,36 +153,35 @@ export default function AISearch() { {step === NeedBuilderStep.PARSING && ( - - KI analysiert Ihren Bedarf… - - - Kriterien werden extrahiert und bewertet - + KI analysiert Ihren Bedarf… + Kriterien werden extrahiert und bewertet )} {/* Step: Criteria Review + Follow-up */} - {isReview && parseResult && ( + {isReview && parseResult && editedCriteria && ( - + setStep(NeedBuilderStep.WEIGHTING_REVIEW)} + onReparse={handleReparse} /> - - - + )} @@ -126,20 +191,16 @@ export default function AISearch() { - @@ -180,10 +252,7 @@ export default function AISearch() { {/* Step: Error */} {step === NeedBuilderStep.ERROR && ( - + )}