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",
|
"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",
|
||||||
|
|||||||
@@ -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,18 +42,15 @@ 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>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
|
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
|
||||||
<Chip
|
<Chip
|
||||||
label={statusCfg.label}
|
label={statusCfg.label}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
|
||||||
bgcolor: statusCfg.bg,
|
|
||||||
color: statusCfg.fg,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
height: 20,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{need.tenantCompany && (
|
{need.tenantCompany && (
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,206 +1,159 @@
|
|||||||
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',
|
|
||||||
'& .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
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
|
||||||
{need.aiSummary}
|
{need.aiSummary}
|
||||||
</Alert>
|
</Typography>
|
||||||
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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} m²`} />
|
||||||
icon={<Ruler size={14} />}
|
|
||||||
label="Fläche"
|
|
||||||
value={`${need.sizeRange.min}–${need.sizeRange.max} m²`}
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
|
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{need.weightedPreferences.map((p, idx) => {
|
{need.weightedPreferences.map((p, idx) => {
|
||||||
const pct = Math.round(p.weight * 100)
|
const pct = Math.round(p.weight * 100)
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
|
||||||
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 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
|
||||||
{p.criterion}
|
<Typography variant="caption"
|
||||||
</Typography>
|
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#152642',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
bgcolor: '#e0e7ff',
|
|
||||||
px: 1,
|
|
||||||
py: 0.125,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pct}%
|
{pct}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
|
||||||
sx={{
|
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: '#e8e7e4',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: `${pct}%`,
|
|
||||||
height: '100%',
|
|
||||||
bgcolor: '#152642',
|
|
||||||
transition: 'width 0.3s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
{p.description && (
|
{p.description && (
|
||||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
|
||||||
@@ -211,57 +164,100 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</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' && (
|
{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' }}>
|
||||||
|
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => openWizard(need.id, need.title)}
|
onClick={() => openWizard(need.id, need.title)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
sx={{
|
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
|
||||||
textTransform: 'none',
|
|
||||||
bgcolor: '#152642',
|
|
||||||
fontWeight: 600,
|
|
||||||
'&:hover': { bgcolor: '#16304d' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Angebot erstellen
|
Angebot erstellen
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user