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}`)