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:
Generated
+48
@@ -41,6 +41,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.61.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
@@ -4330,6 +4331,53 @@
|
||||
"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": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.61.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.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 { PublicNeedList } from './PublicNeedList'
|
||||
import { PublicNeedDetail } from './PublicNeedDetail'
|
||||
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
||||
|
||||
type MobileView = 'list' | 'detail' | 'properties'
|
||||
|
||||
@@ -112,7 +111,6 @@ export function LatentInquiriesTab() {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
||||
|
||||
@@ -9,6 +9,12 @@ interface PublicNeedCardProps {
|
||||
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) {
|
||||
const statusCfg = latentStatusBadge(need.status)
|
||||
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 }}>
|
||||
{need.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={statusCfg.label}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: statusCfg.bg,
|
||||
color: statusCfg.fg,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
|
||||
<Chip
|
||||
label={statusCfg.label}
|
||||
size="small"
|
||||
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{need.tenantCompany && (
|
||||
@@ -74,12 +77,6 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
|
||||
{need.desiredLocation}
|
||||
</Typography>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,242 +1,248 @@
|
||||
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
|
||||
import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react'
|
||||
import { useMemo, useState } from '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 { ResultType, UserRole } from '../../domain/enums'
|
||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
|
||||
import { UserRole } from '../../domain/enums'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
|
||||
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||
|
||||
interface PublicNeedDetailProps {
|
||||
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) {
|
||||
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 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 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 (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
bgcolor: '#f8fafc',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
|
||||
{/* Scrollable content */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Chip
|
||||
label={assetTypeLabel(need.assetType)}
|
||||
size="small"
|
||||
sx={{
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<Chip label={assetTypeLabel(need.assetType)} size="small"
|
||||
sx={{ 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>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
|
||||
{need.title}
|
||||
</Typography>
|
||||
{need.tenantCompany && (
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
{need.tenantCompany}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* AI Summary */}
|
||||
{/* KI-Entscheidungsbrief */}
|
||||
{need.aiSummary && (
|
||||
<Alert
|
||||
icon={<Sparkles size={16} />}
|
||||
severity="info"
|
||||
sx={{
|
||||
bgcolor: '#eff6ff',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #bfdbfe',
|
||||
'& .MuiAlert-icon': { color: '#1d4ed8' },
|
||||
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
|
||||
KI-Analyse
|
||||
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||
<Sparkles size={16} color="#1d4ed8" />
|
||||
<Typography variant="caption"
|
||||
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
|
||||
KI-Entscheidungsbrief
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
|
||||
{need.aiSummary}
|
||||
</Typography>
|
||||
{need.aiSummary}
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Suchprofil grid */}
|
||||
{/* Suchprofil */}
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Suchprofil
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box 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={<Ruler size={14} />}
|
||||
label="Fläche"
|
||||
value={`${need.sizeRange.min}–${need.sizeRange.max} m²`}
|
||||
/>
|
||||
<ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}–${need.sizeRange.max} m²`} />
|
||||
<ProfileBox
|
||||
icon={<Wallet size={14} />}
|
||||
label="Budget"
|
||||
value={
|
||||
need.budgetRange
|
||||
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${
|
||||
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
|
||||
} /m²`
|
||||
: 'Flexibel'
|
||||
}
|
||||
value={need.budgetRange
|
||||
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}–${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
|
||||
: 'Flexibel'}
|
||||
/>
|
||||
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Must-have Kriterien */}
|
||||
{/* Must-haves */}
|
||||
{need.mustHaveCriteria.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
Must-have Kriterien
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{need.mustHaveCriteria.map((c, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle2 size={14} color="#16a34a" />
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>
|
||||
{c}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Gewichtete Präferenzen */}
|
||||
{/* Gewichtete Präferenzen — Accordion */}
|
||||
{need.weightedPreferences.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
|
||||
>
|
||||
Gewichtete Präferenzen
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{need.weightedPreferences.map((p, idx) => {
|
||||
const pct = Math.round(p.weight * 100)
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
{p.criterion}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: '#152642',
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: '#e0e7ff',
|
||||
px: 1,
|
||||
py: 0.125,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</Typography>
|
||||
<Accordion elevation={0} disableGutters
|
||||
sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
|
||||
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
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{need.weightedPreferences.map((p, idx) => {
|
||||
const pct = Math.round(p.weight * 100)
|
||||
return (
|
||||
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
|
||||
<Typography variant="caption"
|
||||
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', 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
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: '#e8e7e4',
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* 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' && (
|
||||
<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.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
onClick={() => openWizard(need.id, need.title)}
|
||||
disabled={disabled}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
bgcolor: '#152642',
|
||||
fontWeight: 600,
|
||||
'&:hover': { bgcolor: '#16304d' },
|
||||
}}
|
||||
>
|
||||
Angebot erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
{selectedIds.length > 0 && !disabled && (
|
||||
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
|
||||
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
onClick={() => openWizard(need.id, need.title)}
|
||||
disabled={disabled}
|
||||
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Angebot erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
@@ -244,24 +250,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
||||
|
||||
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Box 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 }}>
|
||||
{icon}
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: fullWidth ? '100%' : 280,
|
||||
minWidth: fullWidth ? 'unset' : 280,
|
||||
width: fullWidth ? '100%' : 260,
|
||||
minWidth: fullWidth ? 'unset' : 260,
|
||||
flexShrink: 0,
|
||||
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
|
||||
bgcolor: 'white',
|
||||
|
||||
@@ -15,6 +15,7 @@ export { PublicNeedCard } from './PublicNeedCard'
|
||||
export { PublicNeedDetail } from './PublicNeedDetail'
|
||||
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
|
||||
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
|
||||
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
|
||||
export { OfferCreationPanel } from './OfferCreationPanel'
|
||||
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { AssetType } from '../../domain/enums'
|
||||
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> = {
|
||||
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikflä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 {
|
||||
const parts: string[] = []
|
||||
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
|
||||
|
||||
Reference in New Issue
Block a user