feat(supply): Anfragencenter — embedded property cards in public need detail

Add EmbeddedPropertyCard and surface matching properties inside the
public need detail view. Add needToParsedCriteria() mapper to convert a
stored Need back into parsed criteria for re-running search/matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-23 00:03:36 +02:00
parent be5523bcee
commit 07cea21143
9 changed files with 423 additions and 209 deletions
+48
View File
@@ -41,6 +41,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
@@ -4330,6 +4331,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.14", "version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+1
View File
@@ -47,6 +47,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
@@ -0,0 +1,146 @@
import { Box, Button, Checkbox, Paper, Typography } from '@mui/material'
import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils'
interface EmbeddedPropertyCardProps {
property: Property
matchScore: number
selected: boolean
onToggle: () => void
reason: string
onQuickOffer: () => void
disabled?: boolean
}
export function EmbeddedPropertyCard({
property,
matchScore,
selected,
onToggle,
reason,
onQuickOffer,
disabled,
}: EmbeddedPropertyCardProps) {
const tier = getScoreTier(matchScore)
const theme = SCORE_THEME[tier]
const image = property.images?.[0]
return (
<Paper
elevation={0}
sx={{
borderRadius: 2,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f8fafc' : 'white',
overflow: 'hidden',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 8px rgba(15,23,42,0.07)' },
}}
>
{/* Info row */}
<Box sx={{ display: 'flex', gap: 2, p: 2 }}>
<Box
sx={{
width: 72,
height: 72,
flexShrink: 0,
borderRadius: 1.5,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!image && <Building2 size={24} color="#94a3b8" />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
{property.title}
</Typography>
<Box
sx={{
background: theme.gradient,
color: theme.text,
px: 1.25,
py: 0.375,
borderRadius: 1,
fontSize: '0.9rem',
fontWeight: 700,
flexShrink: 0,
border: `1px solid ${theme.border}`,
}}
>
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
{reason}
</Typography>
</Box>
</Box>
{/* Action row */}
<Box
sx={{
borderTop: '1px solid #f1f5f9',
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#fafafa',
}}
>
<Box
onClick={!disabled ? onToggle : undefined}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: disabled ? 'default' : 'pointer' }}
>
<Checkbox
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
size="small"
disabled={disabled}
sx={{ p: 0.5 }}
/>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
Für Angebot auswählen
</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={onQuickOffer}
disabled={disabled}
sx={{
textTransform: 'none',
fontSize: '0.775rem',
fontWeight: 600,
borderColor: '#152642',
color: '#152642',
py: 0.5,
px: 1.5,
minWidth: 0,
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
}}
>
Angebot
</Button>
</Box>
</Paper>
)
}
@@ -5,7 +5,6 @@ import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
import { EmptyState } from '../ui' import { EmptyState } from '../ui'
import { PublicNeedList } from './PublicNeedList' import { PublicNeedList } from './PublicNeedList'
import { PublicNeedDetail } from './PublicNeedDetail' import { PublicNeedDetail } from './PublicNeedDetail'
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
type MobileView = 'list' | 'detail' | 'properties' type MobileView = 'list' | 'detail' | 'properties'
@@ -112,7 +111,6 @@ export function LatentInquiriesTab() {
</Box> </Box>
)} )}
</Box> </Box>
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
</Box> </Box>
) )
} }
@@ -1,5 +1,5 @@
import { Box, Chip, Paper, Typography } from '@mui/material' import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin, Ruler } from 'lucide-react' import { MapPin } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed' import type { LatentNeed } from '../../domain/latentNeed'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils' import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
@@ -9,6 +9,12 @@ interface PublicNeedCardProps {
onClick: () => void onClick: () => void
} }
function opportunityColor(status: 'public' | 'paused' | 'expired'): string {
if (status === 'public') return '#16a34a'
if (status === 'paused') return '#d97706'
return '#94a3b8'
}
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) { export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
const statusCfg = latentStatusBadge(need.status) const statusCfg = latentStatusBadge(need.status)
return ( return (
@@ -36,17 +42,14 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}> <Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
{need.title} {need.title}
</Typography> </Typography>
<Chip <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
label={statusCfg.label} <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
size="small" <Chip
sx={{ label={statusCfg.label}
bgcolor: statusCfg.bg, size="small"
color: statusCfg.fg, sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
fontWeight: 600, />
fontSize: '0.65rem', </Box>
height: 20,
}}
/>
</Box> </Box>
{need.tenantCompany && ( {need.tenantCompany && (
@@ -74,12 +77,6 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
{need.desiredLocation} {need.desiredLocation}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
<Ruler size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
</Paper> </Paper>
) )
} }
+182 -186
View File
@@ -1,242 +1,248 @@
import { Alert, Box, Button, Chip, Typography } from '@mui/material' import { useMemo, useState } from 'react'
import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react' import {
Accordion, AccordionDetails, AccordionSummary,
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Typography,
} from '@mui/material'
import { Calendar, CheckCircle2, ChevronDown, Info, MapPin, Ruler, Sparkles, Target, Wallet } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed' import type { LatentNeed } from '../../domain/latentNeed'
import { ResultType, UserRole } from '../../domain/enums'
import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils' import { useProperties } from '../../hooks/useProperties'
import { UserRole } from '../../domain/enums' import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
interface PublicNeedDetailProps { interface PublicNeedDetailProps {
need: LatentNeed need: LatentNeed
} }
function buildReason(propType: string, needType: string, city: string, location: string): string {
if (propType === needType) {
const match = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
return match ? 'Nutzungstyp und Standort passen sehr gut' : 'Passender Nutzungstyp, alternative Lage'
}
return 'Alternatives Profil — Detailprüfung empfohlen'
}
export function PublicNeedDetail({ need }: PublicNeedDetailProps) { export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
const openWizard = useOfferWizardStore(s => s.open) const openWizard = useOfferWizardStore(s => s.open)
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
const toggleProperty = useOfferWizardStore(s => s.toggleProperty)
const currentUser = useSessionStore(s => s.currentUser) const currentUser = useSessionStore(s => s.currentUser)
const statusCfg = latentStatusBadge(need.status) const statusCfg = latentStatusBadge(need.status)
const [showAll, setShowAll] = useState(false)
const { data: properties = [], isLoading: propertiesLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public' const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
const scored = useMemo(
() =>
properties
.map(p => ({
property: p,
score: deterministicMatchScore(p.id, need.id),
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
}))
.sort((a, b) => b.score - a.score),
[properties, need],
)
const visible = showAll ? scored : scored.slice(0, 3)
const remaining = scored.length - 3
function handleQuickOffer(propertyId: string) {
setSelectedProperties([propertyId])
openWizard(need.id, need.title)
}
return ( return (
<Box <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
sx={{ {/* Scrollable content */}
flex: 1, <Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
overflowY: 'auto',
bgcolor: '#f8fafc',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Header */} {/* Header */}
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip <Chip label={assetTypeLabel(need.assetType)} size="small"
label={assetTypeLabel(need.assetType)} sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
size="small" <Chip label={statusCfg.label} size="small"
sx={{ sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}> <Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
{need.title} {need.title}
</Typography> </Typography>
{need.tenantCompany && ( {need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}> <Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
{need.tenantCompany}
</Typography>
)} )}
</Box> </Box>
{/* AI Summary */} {/* KI-Entscheidungsbrief */}
{need.aiSummary && ( {need.aiSummary && (
<Alert <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
icon={<Sparkles size={16} />} <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
severity="info" <Sparkles size={16} color="#1d4ed8" />
sx={{ <Typography variant="caption"
bgcolor: '#eff6ff', sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
borderRadius: 1.5, KI-Entscheidungsbrief
border: '1px solid #bfdbfe', </Typography>
'& .MuiAlert-icon': { color: '#1d4ed8' }, </Box>
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' }, <Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
}} {need.aiSummary}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
KI-Analyse
</Typography> </Typography>
{need.aiSummary} </Paper>
</Alert>
)} )}
{/* Suchprofil grid */} {/* Suchprofil */}
<Box> <Box>
<Typography <Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Suchprofil Suchprofil
</Typography> </Typography>
<Box <Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
sx={{
mt: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 1.5,
}}
>
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} /> <ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
<ProfileBox <ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}${need.sizeRange.max}`} />
icon={<Ruler size={14} />}
label="Fläche"
value={`${need.sizeRange.min}${need.sizeRange.max}`}
/>
<ProfileBox <ProfileBox
icon={<Wallet size={14} />} icon={<Wallet size={14} />}
label="Budget" label="Budget"
value={ value={need.budgetRange
need.budgetRange ? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${ : 'Flexibel'}
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
} /m²`
: 'Flexibel'
}
/> />
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} /> <ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
</Box> </Box>
</Box> </Box>
{/* Must-have Kriterien */} {/* Must-haves */}
{need.mustHaveCriteria.length > 0 && ( {need.mustHaveCriteria.length > 0 && (
<Box> <Box>
<Typography <Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
Must-have Kriterien Must-have Kriterien
</Typography> </Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => ( {need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" /> <CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}> <Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
{c}
</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
</Box> </Box>
)} )}
{/* Gewichtete Präferenzen */} {/* Gewichtete Präferenzen — Accordion */}
{need.weightedPreferences.length > 0 && ( {need.weightedPreferences.length > 0 && (
<Box> <Accordion elevation={0} disableGutters
<Typography sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
variant="caption" <AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }} sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
> <Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen Gewichtete Präferenzen
</Typography> </Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}> </AccordionSummary>
{need.weightedPreferences.map((p, idx) => { <AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
const pct = Math.round(p.weight * 100) <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
return ( {need.weightedPreferences.map((p, idx) => {
<Box const pct = Math.round(p.weight * 100)
key={idx} return (
sx={{ <Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
bgcolor: 'white', <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
border: '1px solid #e2e8f0', <Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
borderRadius: 1.5, <Typography variant="caption"
px: 1.5, sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
py: 1, {pct}%
}} </Typography>
> </Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}> <Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}> <Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
{p.criterion} </Box>
</Typography> {p.description && (
<Typography <Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
variant="caption" {p.description}
sx={{ </Typography>
fontWeight: 700, )}
color: '#152642',
fontSize: '0.75rem',
bgcolor: '#e0e7ff',
px: 1,
py: 0.125,
borderRadius: 1,
}}
>
{pct}%
</Typography>
</Box> </Box>
<Box )
sx={{ })}
height: 6, </Box>
borderRadius: 3, </AccordionDetails>
bgcolor: '#e8e7e4', </Accordion>
overflow: 'hidden',
}}
>
<Box
sx={{
width: `${pct}%`,
height: '100%',
bgcolor: '#152642',
transition: 'width 0.3s',
}}
/>
</Box>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
{p.description}
</Typography>
)}
</Box>
)
})}
</Box>
</Box>
)} )}
{/* Top Empfehlungen */}
<Box>
<Divider sx={{ mb: 2.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Target size={14} color="#152642" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Ihre Top Empfehlungen
</Typography>
</Box>
{propertiesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
) : scored.length === 0 ? (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
Keine Portfolio-Objekte vorhanden
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{visible.map(({ property, score, reason }) => (
<EmbeddedPropertyCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggleProperty(property.id)}
reason={reason}
onQuickOffer={() => handleQuickOffer(property.id)}
disabled={disabled}
/>
))}
{remaining > 0 && (
<Box
onClick={() => setShowAll(s => !s)}
sx={{
py: 1.25,
textAlign: 'center',
cursor: 'pointer',
color: '#64748b',
fontSize: '0.8rem',
fontWeight: 500,
border: '1px dashed #e2e8f0',
borderRadius: 1.5,
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
transition: 'all 0.15s',
}}
>
{showAll ? '▲ Weniger anzeigen' : `▼ Alle ${scored.length} Objekte anzeigen (+${remaining} weitere)`}
</Box>
)}
</Box>
)}
</Box>
</Box>
{/* Sticky footer CTA */}
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white' }}>
{disabled && need.status !== 'public' && ( {disabled && need.status !== 'public' && (
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem' }}> <Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} kein Angebot möglich. Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} kein Angebot möglich.
</Alert> </Alert>
)} )}
{selectedIds.length > 0 && !disabled && (
<Box sx={{ mt: 'auto', pt: 1 }}> <Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
<Button {selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
variant="contained" </Typography>
size="large" )}
fullWidth <Button
onClick={() => openWizard(need.id, need.title)} variant="contained"
disabled={disabled} size="large"
sx={{ fullWidth
textTransform: 'none', onClick={() => openWizard(need.id, need.title)}
bgcolor: '#152642', disabled={disabled}
fontWeight: 600, sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
'&:hover': { bgcolor: '#16304d' }, >
}} Angebot erstellen
> </Button>
Angebot erstellen
</Button>
</Box>
</Box> </Box>
</Box> </Box>
) )
@@ -244,24 +250,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return ( return (
<Box <Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon} {icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}> <Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label} {label}
</Typography> </Typography>
</Box> </Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}> <Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
{value}
</Typography>
</Box> </Box>
) )
} }
@@ -16,8 +16,8 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
return ( return (
<Box <Box
sx={{ sx={{
width: fullWidth ? '100%' : 280, width: fullWidth ? '100%' : 260,
minWidth: fullWidth ? 'unset' : 280, minWidth: fullWidth ? 'unset' : 260,
flexShrink: 0, flexShrink: 0,
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0', borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
bgcolor: 'white', bgcolor: 'white',
+1
View File
@@ -15,6 +15,7 @@ export { PublicNeedCard } from './PublicNeedCard'
export { PublicNeedDetail } from './PublicNeedDetail' export { PublicNeedDetail } from './PublicNeedDetail'
export { OwnPropertyMatchList } from './OwnPropertyMatchList' export { OwnPropertyMatchList } from './OwnPropertyMatchList'
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard' export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
export { OfferCreationPanel } from './OfferCreationPanel' export { OfferCreationPanel } from './OfferCreationPanel'
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep' export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
export { OfferPdfReviewStep } from './OfferPdfReviewStep' export { OfferPdfReviewStep } from './OfferPdfReviewStep'
+28 -1
View File
@@ -1,12 +1,39 @@
import { AssetType } from '../../domain/enums' import { AssetType } from '../../domain/enums'
import type { ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import type { ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import type { CreateNeedInput } from '../../domain/need' import type { CreateNeedInput, Need } from '../../domain/need'
const ASSET_LABELS_TEXT: Record<string, string> = { const ASSET_LABELS_TEXT: Record<string, string> = {
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche', OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche', PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
} }
export function needToParsedCriteria(need: Need): ParsedNeedCriteria {
return {
assetType: need.assetType,
areaRange: need.requiredArea,
preferredLocations: need.preferredLocations,
budgetRange: need.budgetRange,
timing: need.timing,
mustHaveCriteria: need.mustCriteriaText,
softFactors: need.softFactors,
requireGroundFloor: need.requireGroundFloor,
requiredFitOut: need.requiredFitOut,
requiredParkingMin: need.requiredParkingMin,
requireAirConditioning: need.requireAirConditioning,
requireLoadingDock: need.requireLoadingDock,
requireBarrierFree: need.requireBarrierFree,
minCeilingHeightM: need.minCeilingHeightM,
minContractDurationMonths: need.minContractDurationMonths,
searchRadius: need.searchRadius,
isAnonymous: need.isAnonymous,
requiresDivisibility: need.requiresDivisibility,
minDivisibleUnit: need.minDivisibleUnit,
fitOutBudgetMaxPerSqm: need.fitOutBudgetMaxPerSqm,
notes: need.notes,
companyName: need.companyName,
}
}
export function generateSummary(c: ParsedNeedCriteria): string { export function generateSummary(c: ParsedNeedCriteria): string {
const parts: string[] = [] const parts: string[] = []
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`) if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)