diff --git a/package-lock.json b/package-lock.json
index c18bc82..190de1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index e396f0b..14af4cc 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/anfragencenter/EmbeddedPropertyCard.tsx b/src/components/anfragencenter/EmbeddedPropertyCard.tsx
new file mode 100644
index 0000000..8ea0bce
--- /dev/null
+++ b/src/components/anfragencenter/EmbeddedPropertyCard.tsx
@@ -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 (
+
+ {/* Info row */}
+
+
+ {!image && }
+
+
+
+
+
+ {property.title}
+
+
+ {matchScore}%
+
+
+
+
+
+ {property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
+
+
+
+ {reason}
+
+
+
+
+ {/* Action row */}
+
+
+ e.stopPropagation()}
+ size="small"
+ disabled={disabled}
+ sx={{ p: 0.5 }}
+ />
+
+ Für Angebot auswählen
+
+
+
+
+
+ )
+}
diff --git a/src/components/anfragencenter/LatentInquiriesTab.tsx b/src/components/anfragencenter/LatentInquiriesTab.tsx
index c448599..dcb5fa5 100644
--- a/src/components/anfragencenter/LatentInquiriesTab.tsx
+++ b/src/components/anfragencenter/LatentInquiriesTab.tsx
@@ -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() {
)}
- {selectedNeed && }
)
}
diff --git a/src/components/anfragencenter/PublicNeedCard.tsx b/src/components/anfragencenter/PublicNeedCard.tsx
index b7d8798..8b54d75 100644
--- a/src/components/anfragencenter/PublicNeedCard.tsx
+++ b/src/components/anfragencenter/PublicNeedCard.tsx
@@ -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)
{need.title}
-
+
+
+
+
{need.tenantCompany && (
@@ -74,12 +77,6 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
{need.desiredLocation}
-
-
-
- {need.sizeRange.min}–{need.sizeRange.max} m²
-
-
)
}
diff --git a/src/components/anfragencenter/PublicNeedDetail.tsx b/src/components/anfragencenter/PublicNeedDetail.tsx
index 347fbc2..1c858e7 100644
--- a/src/components/anfragencenter/PublicNeedDetail.tsx
+++ b/src/components/anfragencenter/PublicNeedDetail.tsx
@@ -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 (
-
-
+
+ {/* Scrollable content */}
+
+
{/* Header */}
-
-
+
+
{need.title}
{need.tenantCompany && (
-
- {need.tenantCompany}
-
+ {need.tenantCompany}
)}
- {/* AI Summary */}
+ {/* KI-Entscheidungsbrief */}
{need.aiSummary && (
- }
- severity="info"
- sx={{
- bgcolor: '#eff6ff',
- borderRadius: 1.5,
- border: '1px solid #bfdbfe',
- '& .MuiAlert-icon': { color: '#1d4ed8' },
- '& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
- }}
- >
-
- KI-Analyse
+
+
+
+
+ KI-Entscheidungsbrief
+
+
+
+ {need.aiSummary}
- {need.aiSummary}
-
+
)}
- {/* Suchprofil grid */}
+ {/* Suchprofil */}
-
+
Suchprofil
-
+
} label="Standort" value={need.desiredLocation} />
- }
- label="Fläche"
- value={`${need.sizeRange.min}–${need.sizeRange.max} m²`}
- />
+ } label="Fläche" value={`${need.sizeRange.min}–${need.sizeRange.max} m²`} />
}
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'}
/>
} label="Timing" value={need.timing} />
- {/* Must-have Kriterien */}
+ {/* Must-haves */}
{need.mustHaveCriteria.length > 0 && (
-
+
Must-have Kriterien
{need.mustHaveCriteria.map((c, idx) => (
-
- {c}
-
+ {c}
))}
)}
- {/* Gewichtete Präferenzen */}
+ {/* Gewichtete Präferenzen — Accordion */}
{need.weightedPreferences.length > 0 && (
-
-
- Gewichtete Präferenzen
-
-
- {need.weightedPreferences.map((p, idx) => {
- const pct = Math.round(p.weight * 100)
- return (
-
-
-
- {p.criterion}
-
-
- {pct}%
-
+
+ }
+ sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
+
+ Gewichtete Präferenzen
+
+
+
+
+ {need.weightedPreferences.map((p, idx) => {
+ const pct = Math.round(p.weight * 100)
+ return (
+
+
+ {p.criterion}
+
+ {pct}%
+
+
+
+
+
+ {p.description && (
+
+ {p.description}
+
+ )}
-
-
-
- {p.description && (
-
- {p.description}
-
- )}
-
- )
- })}
-
-
+ )
+ })}
+
+
+
)}
+ {/* Top Empfehlungen */}
+
+
+
+
+
+ Ihre Top Empfehlungen
+
+
+ {propertiesLoading ? (
+
+ ) : scored.length === 0 ? (
+
+ Keine Portfolio-Objekte vorhanden
+
+ ) : (
+
+ {visible.map(({ property, score, reason }) => (
+ toggleProperty(property.id)}
+ reason={reason}
+ onQuickOffer={() => handleQuickOffer(property.id)}
+ disabled={disabled}
+ />
+ ))}
+ {remaining > 0 && (
+ 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)`}
+
+ )}
+
+ )}
+
+
+
+ {/* Sticky footer CTA */}
+
{disabled && need.status !== 'public' && (
- } sx={{ fontSize: '0.8125rem' }}>
+ } sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} — kein Angebot möglich.
)}
-
-
-
-
+ {selectedIds.length > 0 && !disabled && (
+
+ {selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
+
+ )}
+
)
@@ -244,24 +250,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
-
+
{icon}
{label}
-
- {value}
-
+ {value}
)
}
diff --git a/src/components/anfragencenter/PublicNeedList.tsx b/src/components/anfragencenter/PublicNeedList.tsx
index 16a2f87..03a5a0d 100644
--- a/src/components/anfragencenter/PublicNeedList.tsx
+++ b/src/components/anfragencenter/PublicNeedList.tsx
@@ -16,8 +16,8 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
return (
= {
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}`)