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:
Benjamin Sutter
2026-05-17 00:58:10 +02:00
parent e13fe470fb
commit efc406ace9
37 changed files with 4871 additions and 459 deletions
+6 -61
View File
@@ -111,74 +111,19 @@ export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set
/>
</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">
<Section title="Must-haves">
<ExtractedFieldRow
label="Must-have Kriterien (Komma-getrennt)"
label="Pflichtkriterien (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) })}
label="Parkplatzbedarf"
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
missing={c.parkingNeed === undefined}
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
/>
</Section>
+12 -16
View File
@@ -1,17 +1,16 @@
import { useState } from 'react'
import { Box, IconButton, TextField, Typography } from '@mui/material'
import { Pencil } from 'lucide-react'
import { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
interface Props {
label: string
value: string
confidence: number
confidence?: number
missing?: boolean
onEdit?: (value: string) => void
}
export function ExtractedFieldRow({ label, value, confidence, missing = false, onEdit }: Props) {
export function ExtractedFieldRow({ label, value, missing = false, onEdit }: Props) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
@@ -60,19 +59,16 @@ export function ExtractedFieldRow({ label, value, confidence, missing = false, o
{value || '—'}
</Typography>
</Box>
<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>
{onEdit && (
<IconButton
size="small"
className="edit-btn"
sx={{ visibility: 'hidden', p: 0.25, flexShrink: 0 }}
onClick={() => { setEditValue(value); setEditing(true) }}
>
<Pencil size={12} />
</IconButton>
)}
</Box>
)
}
@@ -6,13 +6,16 @@ interface Props {
step: NeedBuilderStep
}
const STEPS = ['Bedarf eingeben', 'Kriterien prüfen', 'Gewichtung', 'Speichern']
const STEPS = ['Suchkriterien & Gewichtung', 'Vorschau & 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
if (
step === S.IDLE ||
step === S.PARSING ||
step === S.PARSED_REQUIRES_REVIEW ||
step === S.CLARIFICATION_REQUIRED
) return 0
return 1
}
export function NeedBuilderProgress({ step }: Props) {
+127 -80
View File
@@ -1,108 +1,155 @@
import { Box, Button, Card, Chip, TextField, Typography, Stack } from '@mui/material'
import { Sparkles, RotateCcw } from 'lucide-react'
import { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
interface Props {
value: string
onChange: (v: string) => void
onSubmit: () => void
criteria: ParsedNeedCriteria
onCriteriaChange: (c: ParsedNeedCriteria) => void
}
const EXAMPLES: Record<string, string> = {
Büro: 'Wir suchen 8001.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 200400 m² in Bern Innenstadt, hohe Passantenfrequenz, Erdgeschoss, max. CHF 150/m². Sofort verfügbar.',
Logistik: 'Lagerhalle 2.0003.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 }> = [
const ASSET_OPTIONS = [
{ 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 },
{ label: 'Gemischt', value: AssetType.MIXED },
]
export function NeedInput({ value, onChange, onSubmit }: Props) {
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Card sx={{ p: 3, 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>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
{children}
</Typography>
)
}
<TextField
multiline
rows={5}
fullWidth
placeholder="Beispiel: Wir suchen 8001.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>
export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
const [locationDraft, setLocationDraft] = useState('')
const [mustHaveDraft, setMustHaveDraft] = useState('')
<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>
function addLocations(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, preferredLocations: [...new Set([...(c.preferredLocations ?? []), ...tokens])] })
setLocationDraft('')
}
{/* Asset-Type Quick Select */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Schnellauswahl Nutzungstyp:
function addMustHaves(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, mustHaveCriteria: [...new Set([...(c.mustHaveCriteria ?? []), ...tokens])] })
setMustHaveDraft('')
}
return (
<Card elevation={0} sx={{ p: 3, border: '1px solid #e2e8f0', height: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>Kriterien verfeinern</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2.5 }}>
Ergänzen oder korrigieren Sie die extrahierten Felder.
</Typography>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
{ASSET_TYPE_OPTIONS.map(opt => (
{/* Asset Type */}
<FieldLabel>Nutzungstyp</FieldLabel>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{ASSET_OPTIONS.map(opt => (
<Chip
key={opt.value}
label={opt.label}
size="small"
variant="outlined"
variant={c.assetType === opt.value ? 'filled' : 'outlined'}
clickable
onClick={() => onChange(EXAMPLES[opt.label] ?? value)}
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === opt.value
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
: {}}
/>
))}
</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>
</Box>
{/* Area */}
<FieldLabel>Fläche (m²)</FieldLabel>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
<TextField
size="small" type="number" placeholder="Min"
value={c.areaRange?.min || ''}
onChange={e => set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="body2" color="text.secondary"></Typography>
<TextField
size="small" type="number" placeholder="Max"
value={c.areaRange?.max || ''}
onChange={e => set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="caption" color="text.secondary">m²</Typography>
</Box>
{/* Location */}
<FieldLabel>Standort</FieldLabel>
<TextField
size="small" fullWidth
placeholder="Stadt oder Region — Enter zum Hinzufügen"
value={locationDraft}
onChange={e => setLocationDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && locationDraft.trim()) addLocations(locationDraft) }}
onBlur={() => { if (locationDraft.trim()) addLocations(locationDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.preferredLocations?.length ?? 0) > 0 ? (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{c.preferredLocations!.map(loc => (
<Chip key={loc} label={loc} size="small"
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
/>
))}
</Stack>
) : <Box sx={{ mb: 2.5 }} />}
{/* Budget */}
<FieldLabel>Budget (max CHF/m²)</FieldLabel>
<TextField
size="small" type="number" placeholder="z.B. 45"
value={c.budgetRange?.maxPerSqm || ''}
onChange={e => set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })}
sx={{ width: 160, mb: 2.5 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
{/* Timing */}
<FieldLabel>Verfügbar ab</FieldLabel>
<TextField
size="small"
placeholder="z.B. Q3 2025 oder 01.09.2025"
value={c.timing?.earliestMoveIn ?? ''}
onChange={e => set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })}
sx={{ width: 220, mb: 2.5 }}
/>
{/* Must-haves */}
<FieldLabel>Must-haves</FieldLabel>
<TextField
size="small" fullWidth
placeholder="z.B. ÖV-Anbindung, Parkplätze — Enter zum Hinzufügen"
value={mustHaveDraft}
onChange={e => setMustHaveDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
onBlur={() => { if (mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{c.mustHaveCriteria!.map(item => (
<Chip key={item} label={item} size="small"
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
/>
))}
</Stack>
)}
</Card>
)
}
+202
View File
@@ -0,0 +1,202 @@
import { useRef, useState } from 'react'
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import { Mic, MicOff, Sparkles, X } from 'lucide-react'
interface Props {
text: string
onTextChange: (s: string) => void
onAiSubmit: () => void
isAnalyzing: boolean
isAutoGen: boolean
}
const EXAMPLES = [
'Büro 8001000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
'Retail-Fläche 200400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
'Lagerhalle 20003000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
]
const isSpeechSupported = typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
const [isRecording, setIsRecording] = useState(false)
const [interimText, setInterimText] = useState('')
const recognitionRef = useRef<any>(null)
const accumulatedRef = useRef('')
function startRecording() {
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition
if (!SpeechAPI) return
accumulatedRef.current = text
const rec = new SpeechAPI()
rec.lang = 'de-DE'
rec.continuous = true
rec.interimResults = true
rec.onresult = (e: any) => {
let finalPart = ''
let interimPart = ''
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript
if (e.results[i].isFinal) finalPart += t
else interimPart += t
}
if (finalPart) {
accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim()
onTextChange(accumulatedRef.current)
}
setInterimText(interimPart)
}
rec.onend = () => {
setIsRecording(false)
setInterimText('')
if (accumulatedRef.current.length >= 15) onAiSubmit()
}
rec.onerror = () => { setIsRecording(false); setInterimText('') }
rec.start()
recognitionRef.current = rec
setIsRecording(true)
}
function stopRecording() {
recognitionRef.current?.stop()
}
// Show interim text inside the field while recording
const displayValue = isRecording && interimText
? (text + (text ? ' ' : '') + interimText)
: text
return (
<Card
elevation={0}
sx={{
p: 3,
border: '1px solid',
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
transition: 'border-color 0.2s',
bgcolor: isRecording ? '#fff5f5' : 'white',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
Bedarf beschreiben
</Typography>
<Typography variant="caption" color="text.secondary">
Schreiben oder sprechen die KI extrahiert alle Kriterien automatisch
</Typography>
</Box>
{isRecording ? (
<Chip
size="small"
label="🎤 Aufnahme läuft…"
onClick={stopRecording}
sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
/>
) : isAnalyzing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert</Typography>
</Box>
) : isAutoGen && text ? (
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}> auto-synchronisiert</Typography>
) : null}
</Box>
{/* Textarea + mic */}
<Box sx={{ position: 'relative' }}>
<TextField
multiline
rows={4}
fullWidth
placeholder="Wir suchen 8001.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={displayValue}
onChange={e => {
setInterimText('')
onTextChange(e.target.value)
}}
disabled={isRecording || isAnalyzing}
slotProps={{ htmlInput: { maxLength: 2000 } }}
sx={{
'& .MuiOutlinedInput-root': {
pr: '52px',
bgcolor: isAutoGen && !isRecording ? '#f0f7ff' : 'transparent',
transition: 'background-color 0.2s',
'& textarea': { color: isRecording && interimText ? '#64748b' : 'inherit' },
},
}}
/>
<Box sx={{ position: 'absolute', bottom: 10, right: 10 }}>
{isRecording ? (
<IconButton
onClick={stopRecording}
size="small"
sx={{
bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' },
animation: 'micPulse 1.2s ease-in-out infinite',
'@keyframes micPulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' },
'50%': { boxShadow: '0 0 0 6px rgba(220,38,38,0)' },
},
}}
>
<MicOff size={16} />
</IconButton>
) : (
<IconButton
onClick={isSpeechSupported ? startRecording : undefined}
size="small"
disabled={isAnalyzing || !isSpeechSupported}
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
>
<Mic size={16} />
</IconButton>
)}
</Box>
</Box>
{/* Actions row */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5 }}>
{/* Example prompts */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{EXAMPLES.map((ex, i) => (
<Chip
key={i}
label={ex.slice(0, 32) + '…'}
size="small"
variant="outlined"
clickable
onClick={() => onTextChange(ex)}
sx={{ fontSize: 10, height: 20 }}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
{text && !isRecording && (
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}>
<X size={14} />
</IconButton>
)}
<Button
size="small"
variant="outlined"
disabled={!text.trim() || isAnalyzing || isRecording}
onClick={onAiSubmit}
endIcon={<Sparkles size={13} />}
sx={{ fontSize: 12, whiteSpace: 'nowrap' }}
>
KI Auto-fill
</Button>
</Box>
</Box>
</Card>
)
}
+37 -37
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Box, Button, Card, Slider, Typography } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
@@ -10,17 +11,36 @@ interface Props {
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
const IMPORTANCE_LABELS = ['', 'Unwichtig', 'Wenig wichtig', 'Wichtig', 'Sehr wichtig', 'Entscheidend']
function handleSlider(key: WeightingKey, pct: number) {
onChange({ ...weights, [key]: pct / 100 })
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
if (max === 0) return Object.fromEntries(WEIGHTING_KEYS.map(k => [k, 3])) as Record<WeightingKey, number>
return Object.fromEntries(
WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))])
) as Record<WeightingKey, number>
}
function rawToWeights(raw: Record<WeightingKey, number>): Record<WeightingKey, number> {
const total = WEIGHTING_KEYS.reduce((s, k) => s + (raw[k] ?? 1), 0)
return Object.fromEntries(
WEIGHTING_KEYS.map(k => [k, (raw[k] ?? 1) / total])
) as Record<WeightingKey, number>
}
export function WeightingEditor({ weights, onChange, assetType }: Props) {
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
function handleSlider(key: WeightingKey, value: number) {
const updated = { ...raw, [key]: value }
setRaw(updated)
onChange(rawToWeights(updated))
}
function handleReset() {
onChange(weightingService.getDefaultWeights(assetType))
const defaults = weightingService.getDefaultWeights(assetType)
setRaw(toRaw(defaults))
onChange(defaults)
}
return (
@@ -28,10 +48,10 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Kriteriengewichtung anpassen
Wichtigkeit der Kriterien
</Typography>
<Typography variant="caption" color="text.secondary">
Passen Sie an, wie stark jedes Kriterium das Matching beeinflusst.
Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet.
</Typography>
</Box>
<Button
@@ -45,24 +65,25 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
</Box>
<Card sx={{ p: 3 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{WEIGHTING_KEYS.map(key => {
const pct = Math.round((weights[key] ?? 0) * 100)
const importance = raw[key] ?? 3
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 variant="caption" color="text.secondary">
{IMPORTANCE_LABELS[importance]}
</Typography>
</Box>
<Slider
value={pct}
min={0}
max={40}
value={importance}
min={1}
max={5}
step={1}
marks
onChange={(_, v) => handleSlider(key, v as number)}
size="small"
sx={{ color: '#1e3a5f' }}
@@ -71,27 +92,6 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
)
})}
</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>
)
+1
View File
@@ -7,4 +7,5 @@ export { NeedBuilderErrorState } from './NeedBuilderErrorState'
export { NeedBuilderProgress } from './NeedBuilderProgress'
export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor'