Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a825672197 | |||
| 36570c5bdc | |||
| 98d2770054 | |||
| 6fb2fb027c | |||
| 7f6b7766e6 | |||
| 63909c8caf | |||
| d8ff380d6e | |||
| 81d66d24a9 | |||
| e597e81ff5 | |||
| d2af8664f4 | |||
| cd220ed282 | |||
| a9ee53f185 | |||
| fe8444c1d3 | |||
| 453f2b1982 | |||
| d804d1c923 | |||
| a9e038a64f | |||
| 61a26d00a7 | |||
| f2edc3b2ef | |||
| e95490eb72 | |||
| 3ab6eeccf2 | |||
| 128d28af8d | |||
| 9df5d285f5 | |||
| 609a3634bd |
@@ -11,7 +11,9 @@
|
|||||||
"Bash(start http://localhost:5173)",
|
"Bash(start http://localhost:5173)",
|
||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(git pull *)",
|
"Bash(git pull *)",
|
||||||
"Bash(node scripts/check-tokens.js)"
|
"Bash(node scripts/check-tokens.js)",
|
||||||
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"Bash(echo \"EXIT_CODE:$?\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -m ' *)",
|
||||||
"Bash(git commit *)"
|
"Bash(git commit *)",
|
||||||
|
"Bash(node -e ' *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
> Why do I have a folder named ".vercel" in my project?
|
||||||
|
The ".vercel" folder is created when you link a directory to a Vercel project.
|
||||||
|
|
||||||
|
> What does the "project.json" file contain?
|
||||||
|
The "project.json" file contains:
|
||||||
|
- The ID of the Vercel project that you linked ("projectId")
|
||||||
|
- The ID of the user or team your Vercel project is owned by ("orgId")
|
||||||
|
|
||||||
|
> Should I commit the ".vercel" folder?
|
||||||
|
No, you should not share the ".vercel" folder with anyone.
|
||||||
|
Upon creation, it will be automatically added to your ".gitignore" file.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
|
||||||
+1
-3
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
|
|||||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
||||||
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
|
||||||
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||||
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
|
|||||||
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
||||||
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||||
|
|
||||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -62,7 +61,6 @@ function App() {
|
|||||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
|
||||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export function InquiryReplyComposer({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
bgcolor: 'white',
|
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
|
|||||||
@@ -1,18 +1,86 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Divider, Typography } from '@mui/material'
|
||||||
import { FileText } from 'lucide-react'
|
import { Building2, FileText, MapPin } from 'lucide-react'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { mockProperties } from '../../mock-data/properties'
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
interface MockPdfPreviewProps {
|
interface MockPdfPreviewProps {
|
||||||
fields: Record<string, string>
|
fields: Record<string, string>
|
||||||
fallbackFields: Record<string, string>
|
fallbackFields: Record<string, string>
|
||||||
|
fieldSelections: ReportObjectFieldSelection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
|
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
|
||||||
|
areaSqm: 'Fläche',
|
||||||
|
rentPricePerSqm: 'Mietpreis',
|
||||||
|
availabilityDate: 'Verfügbar ab',
|
||||||
|
leaseTerm: 'Mietlaufzeit',
|
||||||
|
breakoutOption: 'Breakout-Option',
|
||||||
|
currentTenant: 'Aktueller Mieter',
|
||||||
|
propertyNumber: 'Objektnummer',
|
||||||
|
assetType: 'Asset Type',
|
||||||
|
floor: 'Stockwerk',
|
||||||
|
parking: 'Parkplätze',
|
||||||
|
fitOut: 'Ausbaugrad',
|
||||||
|
isBarrierFree: 'Barrierefrei',
|
||||||
|
ceilingHeightM: 'Raumhöhe',
|
||||||
|
floorLoad: 'Bodenlast',
|
||||||
|
loadingDocksCount: 'Anlieferung',
|
||||||
|
goodsLift: 'Warenaufzug',
|
||||||
|
passengerLift: 'Personenaufzug',
|
||||||
|
powerSupplyKva: 'Stromanschluss',
|
||||||
|
hasServerRoom: 'Serverraum',
|
||||||
|
internet: 'Internet',
|
||||||
|
deliveryAccess: 'Zufahrt',
|
||||||
|
publicTransportScore: 'ÖV-Anbindung',
|
||||||
|
prestigeScore: 'Prestige',
|
||||||
|
visibilityScore: 'Sichtbarkeit',
|
||||||
|
footfallScore: 'Passantenfrequenz',
|
||||||
|
commuterAccessScore: 'Pendlererreichbarkeit',
|
||||||
|
talentAccessScore: 'Talent Access',
|
||||||
|
esgScore: 'ESG',
|
||||||
|
flexibilityScore: 'Flexibilität',
|
||||||
|
expansionPotentialScore: 'Expansionspotenzial',
|
||||||
|
taxEnvironmentScore: 'Steuerumfeld',
|
||||||
|
microLocation: 'Mikrostandort',
|
||||||
|
competitionEnvironment: 'Konkurrenzumfeld',
|
||||||
|
infrastructure: 'Infrastruktur',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
|
||||||
|
switch (key) {
|
||||||
|
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : null
|
||||||
|
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
|
||||||
|
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
|
||||||
|
case 'leaseTerm': return property.leaseTerm ?? null
|
||||||
|
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
|
||||||
|
case 'currentTenant': return property.currentTenant ?? null
|
||||||
|
case 'propertyNumber': return property.propertyNumber ?? null
|
||||||
|
case 'assetType': return property.assetType ?? null
|
||||||
|
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
|
||||||
|
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
|
||||||
|
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
|
||||||
|
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
|
||||||
|
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
|
||||||
|
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
|
||||||
|
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
|
||||||
|
case 'fitOut': return property.hardFacts?.fitOut ?? null
|
||||||
|
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
|
||||||
|
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
|
||||||
|
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
|
||||||
|
case 'description': return property.description ?? null
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
|
||||||
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
|
||||||
|
|
||||||
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
||||||
|
|
||||||
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* ── Page 1 — Offer letter ── */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
||||||
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||||
{v('closing')}
|
{v('closing')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* ── Page 2 — Property details side by side ── */}
|
||||||
|
{hasPropertyPage && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||||
|
Seite 2 — Objektdetails
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map(p => {
|
||||||
|
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
|
||||||
|
const optionalFields = selection?.selectedOptionalFields ?? []
|
||||||
|
const image = p.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Photo */}
|
||||||
|
{image ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={image}
|
||||||
|
alt={p.title}
|
||||||
|
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={22} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Box sx={{ p: 1.25, flex: 1 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
|
||||||
|
<MapPin size={10} color="#94a3b8" />
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
|
||||||
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Selected fields */}
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
|
{optionalFields.map(key => {
|
||||||
|
const val = getFieldValue(p, key)
|
||||||
|
if (!val) return null
|
||||||
|
return (
|
||||||
|
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
|
||||||
|
{FIELD_LABELS[key] ?? key}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
|
||||||
|
{val}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
||||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
@@ -187,7 +187,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
size="small"
|
size="small"
|
||||||
value={a.date}
|
value={a.date}
|
||||||
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||||
InputLabelProps={{ shrink: true }}
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
sx={{ width: 160 }}
|
sx={{ width: 160 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
ready={ready}
|
ready={ready}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
property={property}
|
property={property ?? undefined}
|
||||||
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||||
onAttach={() => {
|
onAttach={() => {
|
||||||
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Box, Button, Stack, Typography } from '@mui/material'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
|
||||||
|
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
||||||
|
|
||||||
|
export function OfferFieldSelectionStep() {
|
||||||
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
|
||||||
|
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
|
||||||
|
const { data: allProperties = [] } = useProperties()
|
||||||
|
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
|
||||||
|
// Initialise selections when properties are loaded or change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProperties.length === 0) return
|
||||||
|
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
|
||||||
|
const missing = selectedProperties.filter(p => !existing.has(p.id))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setFieldSelections([
|
||||||
|
...fieldSelections,
|
||||||
|
...missing.map(p => ({
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// run only when property list or selections change length
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedProperties.length, fieldSelections.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
|
||||||
|
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
|
||||||
|
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{selectedProperties.map(p => {
|
||||||
|
const selection: ReportObjectFieldSelection =
|
||||||
|
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ReportObjectFieldSelector
|
||||||
|
key={p.id}
|
||||||
|
propertyTitle={p.title}
|
||||||
|
value={selection}
|
||||||
|
onChange={v => updateFieldSelection(p.id, v)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => setStep('select_properties')}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setStep('pdf_review')}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||||
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||||
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
|
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOfferDraftId(res.data.id)
|
setOfferDraftId(res.data.id)
|
||||||
setStep('pdf_review')
|
setStep('select_fields')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!need) {
|
if (!need) {
|
||||||
|
|||||||
@@ -12,22 +12,25 @@ import {
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
||||||
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
|
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
|
||||||
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
import { OfferCheckedAction } from './OfferCheckedAction'
|
import { OfferCheckedAction } from './OfferCheckedAction'
|
||||||
import { OfferChatComposer } from './OfferChatComposer'
|
import { OfferChatComposer } from './OfferChatComposer'
|
||||||
|
|
||||||
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
|
// 'checked' is internal — merged into pdf_review step; 4 visible steps
|
||||||
const STEPS: { key: OfferStep; label: string }[] = [
|
const STEPS: { key: OfferStep; label: string }[] = [
|
||||||
{ key: 'select_properties', label: 'Objekte wählen' },
|
{ key: 'select_properties', label: 'Objekte wählen' },
|
||||||
|
{ key: 'select_fields', label: 'Felder wählen' },
|
||||||
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
||||||
{ key: 'send', label: 'Senden' },
|
{ key: 'send', label: 'Senden' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
||||||
select_properties: 0,
|
select_properties: 0,
|
||||||
pdf_review: 1,
|
select_fields: 1,
|
||||||
checked: 1, // same visual step as pdf_review
|
pdf_review: 2,
|
||||||
send: 2,
|
checked: 2, // same visual step as pdf_review
|
||||||
|
send: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OfferWizard() {
|
export function OfferWizard() {
|
||||||
@@ -48,13 +51,15 @@ export function OfferWizard() {
|
|||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
slotProps={{
|
||||||
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
height: fullScreen ? '100vh' : '85vh',
|
height: fullScreen ? '100vh' : '85vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -112,6 +117,7 @@ export function OfferWizard() {
|
|||||||
{/* Step content */}
|
{/* Step content */}
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
||||||
|
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
|
||||||
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
||||||
{currentStep === 'checked' && <OfferCheckedAction />}
|
{currentStep === 'checked' && <OfferCheckedAction />}
|
||||||
{currentStep === 'send' && <OfferChatComposer />}
|
{currentStep === 'send' && <OfferChatComposer />}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Download, Send, X } from 'lucide-react'
|
import { Download, Send, X } from 'lucide-react'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
import type { Property } from '../../domain/property'
|
|
||||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
@@ -40,10 +39,6 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
|
|
||||||
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
||||||
|
|
||||||
const selectedProperties = draft?.selectedPropertyIds
|
|
||||||
.map(id => allProperties.find(p => p.id === id))
|
|
||||||
.filter((p): p is Property => !!p) ?? []
|
|
||||||
|
|
||||||
const toggleProperty = (id: string) => {
|
const toggleProperty = (id: string) => {
|
||||||
setSelectedIds(prev =>
|
setSelectedIds(prev =>
|
||||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||||
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
|
||||||
|
|
||||||
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
||||||
const isSelected = (key: ReportObjectFieldKey) =>
|
const isSelected = (key: ReportObjectFieldKey) =>
|
||||||
value.selectedOptionalFields.includes(key)
|
value.selectedOptionalFields.includes(key)
|
||||||
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
|
|||||||
|
|
||||||
{FIELD_GROUPS.map(group => {
|
{FIELD_GROUPS.map(group => {
|
||||||
const groupKeys = group.fields.map(f => f.key)
|
const groupKeys = group.fields.map(f => f.key)
|
||||||
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
|
|
||||||
return (
|
return (
|
||||||
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
||||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { Chip } from '@mui/material'
|
||||||
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
|
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
|
||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
import { DS_COLORS } from '../../lib/ds'
|
||||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
||||||
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
|
|||||||
|
|
||||||
const ICONS: Record<ResultType, React.ElementType> = {
|
const ICONS: Record<ResultType, React.ElementType> = {
|
||||||
VERIFIED_PORTFOLIO: ShieldCheck,
|
VERIFIED_PORTFOLIO: ShieldCheck,
|
||||||
EXTERNAL_MARKET: Globe,
|
|
||||||
MAISON_WORK: Building2,
|
MAISON_WORK: Building2,
|
||||||
FUTURE_AVAILABILITY: Sparkles,
|
FUTURE_AVAILABILITY: Sparkles,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
|
|||||||
|
|
||||||
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
|
|
||||||
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { TableCell, TableRow } from '@mui/material'
|
||||||
|
import { LABEL_SX, DATA_SX } from './compareUtils'
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CompareMetricRowProps {
|
||||||
|
label: string
|
||||||
|
cells: ReactNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CompareMetricRow = memo(function CompareMetricRow({ label, cells }: CompareMetricRowProps) {
|
||||||
|
return (
|
||||||
|
<TableRow hover>
|
||||||
|
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||||
|
{cells.map((cell, i) => (
|
||||||
|
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
|
|||||||
inq: Inquiry
|
inq: Inquiry
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
hasPipeline: boolean
|
hasPipeline: boolean
|
||||||
|
perspective?: 'demand' | 'supply'
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
|
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
|
||||||
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
||||||
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
||||||
const lastMsg = inq.thread[inq.thread.length - 1]
|
const lastMsg = inq.thread[inq.thread.length - 1]
|
||||||
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
||||||
|
|
||||||
|
const primaryTitle = perspective === 'demand'
|
||||||
|
? (inq.propertyAddress ?? inq.subject)
|
||||||
|
: inq.tenantName
|
||||||
|
|
||||||
|
const secondaryLine = perspective === 'demand'
|
||||||
|
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
|
||||||
|
: (inq.tenantCompany ?? '')
|
||||||
|
|
||||||
|
const lastMsgPrefix = (() => {
|
||||||
|
if (!lastMsg) return ''
|
||||||
|
if (perspective === 'demand') {
|
||||||
|
return lastMsg.senderType === 'tenant'
|
||||||
|
? 'Sie: '
|
||||||
|
: `${inq.propertyManagerName ?? 'Verwalter'}: `
|
||||||
|
}
|
||||||
|
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box onClick={() => onSelect(inq.id)} sx={{
|
<Box onClick={() => onSelect(inq.id)} sx={{
|
||||||
px: 2, py: 1.5,
|
px: 2, py: 1.5,
|
||||||
@@ -31,7 +50,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
||||||
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
||||||
{inq.tenantName}
|
{primaryTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||||
@@ -43,9 +62,9 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{inq.tenantCompany && (
|
{secondaryLine && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
||||||
{inq.tenantCompany}
|
{secondaryLine}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
||||||
@@ -53,7 +72,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
</Typography>
|
</Typography>
|
||||||
{lastMsg && (
|
{lastMsg && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
||||||
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
|
{lastMsgPrefix}
|
||||||
{lastMsg.body.split('\n')[0]}
|
{lastMsg.body.split('\n')[0]}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
|
|||||||
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
|
|
||||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
|
||||||
const isOwnMessage = msg.senderType === 'tenant'
|
const isOwnMessage = msg.senderType === 'tenant'
|
||||||
const isAI = msg.senderType === 'ai'
|
const isAI = msg.senderType === 'ai'
|
||||||
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
{msg.senderName} · {date} {time}
|
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
|||||||
{ label: 'Retail', value: AssetType.RETAIL },
|
{ label: 'Retail', value: AssetType.RETAIL },
|
||||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useCompareStore } from '../../stores/compareStore'
|
|||||||
|
|
||||||
const TYPE_DOT: Record<string, string> = {
|
const TYPE_DOT: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||||
EXTERNAL_MARKET: '#d97706',
|
|
||||||
MAISON_WORK: '#0369a1',
|
MAISON_WORK: '#0369a1',
|
||||||
FUTURE_AVAILABILITY: '#7c3aed',
|
FUTURE_AVAILABILITY: '#7c3aed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
Target,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Search,
|
Search,
|
||||||
List,
|
List,
|
||||||
@@ -13,7 +12,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Kanban,
|
Kanban,
|
||||||
BellRing,
|
BellRing,
|
||||||
Plus,
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -50,12 +49,10 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||||
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
||||||
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
|
|
||||||
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
|
||||||
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
|
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
||||||
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
[WorkspaceType.DEMAND]: {
|
[WorkspaceType.DEMAND]: {
|
||||||
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
[WorkspaceType.OPERATIONS]: {
|
||||||
|
label: 'Operations',
|
||||||
|
abbreviation: 'OP',
|
||||||
|
icon: Settings,
|
||||||
|
firstPath: '/ops/review-queue',
|
||||||
|
chipColor: '#6b21a8',
|
||||||
|
navItems: [
|
||||||
|
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
|
||||||
|
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordered list for rendering workspace tabs
|
// Ordered list for rendering workspace tabs
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Button, Divider, Typography } from '@mui/material'
|
import { Box, Button, Divider, Typography } from '@mui/material'
|
||||||
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
|||||||
|
|
||||||
// ── Future Availability Card ──────────────────────────────────────────────────
|
// ── Future Availability Card ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||||
const isControlled = vm.signalIsControlled ?? false
|
const isControlled = vm.signalIsControlled ?? false
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -243,4 +244,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { HeatBadge } from '../shared/HeatBadge'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
@@ -19,12 +20,15 @@ interface Props {
|
|||||||
lat?: number
|
lat?: number
|
||||||
lng?: number
|
lng?: number
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
@@ -51,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', top: 10, left: 10,
|
position: 'absolute', top: 10, left: 10,
|
||||||
background: theme.gradient, borderRadius: '10px',
|
background: theme.gradient, borderRadius: '10px',
|
||||||
@@ -140,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
{vm.actions.map(a => (
|
{vm.actions.map(a => (
|
||||||
<Button
|
<Button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
|
|||||||
@@ -1,119 +1,216 @@
|
|||||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
import { memo } from 'react'
|
||||||
import { MapPin } from 'lucide-react'
|
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||||
import { MatchCardHeader } from './MatchCardHeader'
|
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||||
import { MatchReasonList } from './MatchReasonList'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { TradeoffList } from './TradeoffList'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||||
|
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
|
imageUrl?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchCardCompact({ vm }: Props) {
|
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||||
|
|
||||||
const borderColor = vm.isCompareSelected
|
const { currentUser } = useSessionStore()
|
||||||
? '#7c3aed'
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
: vm.isSelected
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
? '#1e3a5f'
|
|
||||||
: 'transparent'
|
const tier = getScoreTier(vm.matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
|
||||||
|
|
||||||
|
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
p: 2.5,
|
|
||||||
mb: 1.5,
|
mb: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
border: `2px solid ${borderColor}`,
|
border: `2px solid ${borderColor}`,
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
||||||
|
background: theme.cardBg,
|
||||||
opacity: vm.isStaleData ? 0.75 : 1,
|
opacity: vm.isStaleData ? 0.75 : 1,
|
||||||
transition: 'border-color 0.15s',
|
transition: 'box-shadow 0.15s',
|
||||||
}}
|
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
|
||||||
>
|
}}>
|
||||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
|
||||||
|
{/* ── Image strip ── */}
|
||||||
|
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
width: '100%', height: '100%', minHeight: 120,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
|
||||||
|
}}>
|
||||||
|
<MapPin size={24} color="#7ba3bf" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Score badge */}
|
||||||
|
<Box sx={{
|
||||||
|
position: 'absolute', top: 8, left: 8,
|
||||||
|
background: theme.gradient, borderRadius: '8px',
|
||||||
|
px: 1, py: 0.25,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: `0 2px 8px ${theme.glow}`,
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
|
||||||
|
{vm.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Asset type label */}
|
||||||
|
{overlayTypeLabel && (
|
||||||
|
<Box sx={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||||
|
px: 0.75, py: 0.5,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
{vm.disclaimer && (
|
{vm.disclaimer && (
|
||||||
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||||
{vm.disclaimer}
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vm.isReviewRequired && (
|
|
||||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
{/* Badges row */}
|
||||||
Manuelle Überprüfung erforderlich
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
</Alert>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
|
<Chip label={rt.label} size="small"
|
||||||
|
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
|
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
|
||||||
|
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||||
)}
|
)}
|
||||||
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
|
{vm.availabilityLabel && (
|
||||||
|
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||||
|
)}
|
||||||
|
{topRisk && (
|
||||||
|
<Chip
|
||||||
|
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
|
||||||
|
size="small" color="error" variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Header: score → type → confidence → availability → risk */}
|
{/* Title + location */}
|
||||||
<MatchCardHeader vm={vm} compact />
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||||
|
|
||||||
{/* Title + location (max 2 lines) */}
|
|
||||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
|
||||||
{vm.title}
|
{vm.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{vm.propertySubtitle && (
|
{vm.propertySubtitle && (
|
||||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
|
||||||
{vm.propertySubtitle}
|
{vm.propertySubtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||||
<MapPin size={13} color="#64748b" />
|
<MapPin size={11} color="#64748b" />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{vm.locationLabel}
|
{vm.locationLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Explainability summary */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
|
||||||
variant="body2"
|
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
mt: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.25 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
{/* Score formula — always visible */}
|
{/* Score breakdown */}
|
||||||
{vm.scoreBreakdown && (
|
{vm.scoreBreakdown && (
|
||||||
<Box sx={{ mb: 1.25 }}>
|
<Box sx={{ mb: 1 }}>
|
||||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top reason only */}
|
{/* Reasons — all 3, same style as grid card */}
|
||||||
{vm.reasons.length > 0 && (
|
{vm.reasons.length > 0 && (
|
||||||
<Box sx={{ mb: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
||||||
|
{r.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
|
||||||
|
{r.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top tradeoff only (compact) */}
|
{/* Top tradeoff */}
|
||||||
{vm.tradeoffs.length > 0 && (
|
{vm.tradeoffs.length > 0 && (
|
||||||
<Box sx={{ mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||||
|
{vm.tradeoffs[0].concern}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Data quality — only critical warning in compact mode */}
|
{/* Actions */}
|
||||||
<MatchDataQualitySummary
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
dataQualityScore={vm.dataQualityScore}
|
<Button size="small" variant="contained" onClick={e => {
|
||||||
missingData={vm.missingData}
|
e.stopPropagation()
|
||||||
compact
|
openInquiryDialog({
|
||||||
/>
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
<Box sx={{ mt: 1.25 }}>
|
matchScore: vm.matchScore,
|
||||||
<MatchActionToolbar actions={vm.actions} compact />
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
|
{vm.actions.map(a => (
|
||||||
|
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||||
|
onClick={a.onClick} disabled={a.disabled}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
|||||||
export { MatchActionToolbar } from './MatchActionToolbar'
|
export { MatchActionToolbar } from './MatchActionToolbar'
|
||||||
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
||||||
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
|
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||||
export type {
|
export type {
|
||||||
MatchCardViewModel,
|
MatchCardViewModel,
|
||||||
MatchCardVariant,
|
MatchCardVariant,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
import { FileImage } from 'lucide-react'
|
||||||
|
import { DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface FloorPlan {
|
||||||
|
url: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plans: FloorPlan[]
|
||||||
|
mb?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanSection({ plans, mb }: Props) {
|
||||||
|
if (plans.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<FileImage size={15} color={DS_TEXT.secondary} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{plans.map((p, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
{p.label && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
|
||||||
|
{p.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={p.url}
|
||||||
|
alt={p.label ?? 'Grundriss'}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'contain',
|
||||||
|
maxHeight: 480,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||||
|
TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Send } from 'lucide-react'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
import { useMoveStage } from '../../hooks/usePipeline'
|
||||||
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||||
|
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
|
||||||
|
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryQuickDialog() {
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
||||||
|
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||||
|
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||||
|
const addInquiry = useInquiryStore(s => s.addInquiry)
|
||||||
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogOpen && pendingInquiry) {
|
||||||
|
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
|
||||||
|
}
|
||||||
|
}, [dialogOpen, pendingInquiry])
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!pendingInquiry) return
|
||||||
|
addInquiry(pendingInquiry, message)
|
||||||
|
if (pendingInquiry.pipelineItemId) {
|
||||||
|
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||||
|
}
|
||||||
|
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
|
||||||
|
closeInquiryDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingInquiry) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Send size={16} />
|
||||||
|
Anfrage senden
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ pt: 0 }}>
|
||||||
|
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{pendingInquiry.location}
|
||||||
|
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
|
||||||
|
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
|
||||||
|
{' · '}Match {pendingInquiry.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
|
||||||
|
Der Text kann vor dem Absenden angepasst werden.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||||
|
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!message.trim()}
|
||||||
|
startIcon={<Send size={14} />}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import type { FutureSignal } from '../../domain/futureSignal'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||||
import { useMatchDetail } from '../../hooks/useMatches'
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||||
|
|
||||||
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Grundriss */}
|
||||||
|
{(() => {
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
if (matchedUnit?.floorPlanUrl) {
|
||||||
|
const lbl = matchedUnit.unitLabel
|
||||||
|
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
|
||||||
|
: FLOOR_LABEL(matchedUnit.floorLevel)
|
||||||
|
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
|
||||||
|
} else {
|
||||||
|
units.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Beschreibung */}
|
{/* Beschreibung */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{property.dataQuality.lastVerifiedAt && (
|
{property.dataQuality.lastVerifiedAt && (
|
||||||
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
||||||
Zum Originalinserat
|
Zum Originalinserat
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { PropertyMap } from '../shared'
|
||||||
|
import {
|
||||||
|
ExecutiveSummaryPanel,
|
||||||
|
NeedAlignmentPanel,
|
||||||
|
LocationIntelligencePanel,
|
||||||
|
TradeoffPanel,
|
||||||
|
RiskPanel,
|
||||||
|
MissingInformationPanel,
|
||||||
|
FutureAvailabilityContextPanel,
|
||||||
|
ScoreBreakdownPanel,
|
||||||
|
} from '.'
|
||||||
|
import { MatchDetailPropertySections } from './MatchDetailPropertySections'
|
||||||
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import type { Need } from '../../domain/need'
|
||||||
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||||
|
|
||||||
|
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
match: Match
|
||||||
|
property: Property | null
|
||||||
|
need: Need | null
|
||||||
|
signal: FutureSignal | null
|
||||||
|
isFuture: boolean
|
||||||
|
taxCalculatorUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown({
|
||||||
|
match,
|
||||||
|
property,
|
||||||
|
need,
|
||||||
|
signal,
|
||||||
|
isFuture,
|
||||||
|
taxCalculatorUrl,
|
||||||
|
}: Props) {
|
||||||
|
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Full analysis toggle ── */}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setShowFullAnalysis(v => !v)}
|
||||||
|
fullWidth
|
||||||
|
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||||
|
sx={{
|
||||||
|
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
|
||||||
|
textTransform: 'none', fontWeight: 500,
|
||||||
|
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* ── Full analysis (hidden by default) ── */}
|
||||||
|
{showFullAnalysis && (
|
||||||
|
<>
|
||||||
|
<ExecutiveSummaryPanel match={match} />
|
||||||
|
<NeedAlignmentPanel match={match} need={need} property={property} />
|
||||||
|
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
|
||||||
|
<LocationIntelligencePanel property={property} />
|
||||||
|
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
||||||
|
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
||||||
|
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<PropertyMap
|
||||||
|
lat={property.location.coordinates.lat}
|
||||||
|
lng={property.location.coordinates.lng}
|
||||||
|
label={property.title}
|
||||||
|
height={220}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
<TradeoffPanel match={match} />
|
||||||
|
<RiskPanel match={match} />
|
||||||
|
<MissingInformationPanel match={match} />
|
||||||
|
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
||||||
|
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,23 +1,17 @@
|
|||||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
import type { Match, NextBestAction } from '../../domain/match'
|
import type { Match, NextBestAction } from '../../domain/match'
|
||||||
|
|
||||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
|
|
||||||
HIGH: 'contained',
|
|
||||||
MEDIUM: 'outlined',
|
|
||||||
LOW: 'outlined',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
onCompare?: () => void
|
onCompare?: () => void
|
||||||
onShortlist?: () => void
|
onPipeline?: () => void
|
||||||
onReject?: () => void
|
onInquire?: () => void
|
||||||
onReview?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
|
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
|
||||||
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
||||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
||||||
)
|
)
|
||||||
@@ -26,19 +20,27 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
||||||
|
|
||||||
{engineActions.length > 0 && (
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
{onInquire && (
|
||||||
{engineActions.map((action, i) => (
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<MessageSquare size={14} />}
|
||||||
|
onClick={onInquire}
|
||||||
|
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{engineActions.length > 0 && engineActions.map((action, i) => (
|
||||||
<Box key={i}>
|
<Box key={i}>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
variant={PRIORITY_COLOR[action.priority]}
|
variant="outlined"
|
||||||
sx={
|
sx={{ justifyContent: 'flex-start' }}
|
||||||
action.priority === 'HIGH'
|
|
||||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
|
|
||||||
: { justifyContent: 'flex-start' }
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -49,14 +51,10 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Standard actions */}
|
{onPipeline && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
|
||||||
{onShortlist && (
|
Zur Pipeline hinzufügen
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
|
|
||||||
Zur Shortlist hinzufügen
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCompare && (
|
{onCompare && (
|
||||||
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
Zum Vergleich hinzufügen
|
Zum Vergleich hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onReview && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
|
|
||||||
Zur Überprüfung senden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onReject && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
|
|
||||||
Match ablehnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Layers,
|
Layers,
|
||||||
ShieldCheck,
|
|
||||||
Tag,
|
Tag,
|
||||||
Train,
|
Train,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import {
|
import {
|
||||||
ASSET_LABELS,
|
ASSET_LABELS,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SOURCE_LABELS,
|
SOURCE_LABELS,
|
||||||
UnitRow,
|
UnitRow,
|
||||||
} from './MatchDetailPropertyDetails'
|
} from './MatchDetailPropertyDetails'
|
||||||
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
interface PropertyDetailPublicSectionsProps {
|
interface PropertyDetailPublicSectionsProps {
|
||||||
@@ -238,6 +239,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Grundriss ── */}
|
||||||
|
{(() => {
|
||||||
|
const allUnits = property.units ?? []
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} mb={2} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── Beschreibung ── */}
|
{/* ── Beschreibung ── */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
@@ -270,7 +283,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
||||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||||
|
export { MatchDetailHero } from './MatchDetailHero'
|
||||||
|
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
|
||||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||||
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
|
|||||||
<TextField
|
<TextField
|
||||||
label="Fläche (m²)" value={areaSqm}
|
label="Fläche (m²)" value={areaSqm}
|
||||||
onChange={e => onAreaSqmChange(e.target.value)}
|
onChange={e => onAreaSqmChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
floorPlanUrl: string
|
||||||
|
onFloorPlanUrlChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||||
|
URL zu einem Grundrissbild — hilft Interessenten, sich die Fläche vorzustellen.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Grundriss-URL"
|
||||||
|
value={floorPlanUrl}
|
||||||
|
onChange={e => onFloorPlanUrlChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
{floorPlanUrl && (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={floorPlanUrl}
|
||||||
|
alt="Grundriss Vorschau"
|
||||||
|
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,12 +38,12 @@ export function TechnicalDetailsSection({
|
|||||||
<TextField
|
<TextField
|
||||||
label="Parkplätze" value={parking}
|
label="Parkplätze" value={parking}
|
||||||
onChange={e => onParkingChange(e.target.value)}
|
onChange={e => onParkingChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||||
onChange={e => onCeilingHeightChange(e.target.value)}
|
onChange={e => onCeilingHeightChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
|||||||
export { ImageUrlSection } from './ImageUrlSection'
|
export { ImageUrlSection } from './ImageUrlSection'
|
||||||
export { ContactSection } from './ContactSection'
|
export { ContactSection } from './ContactSection'
|
||||||
export { CreatedScreen } from './CreatedScreen'
|
export { CreatedScreen } from './CreatedScreen'
|
||||||
|
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
|
|||||||
open={open}
|
open={open}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
disableEscapeKeyDown
|
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||||
{SLIDES[activeStep]}
|
{SLIDES[activeStep]}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||||
import { useDraggable } from '@dnd-kit/core'
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
|
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
|
||||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||||
import { HeatBadge } from '../shared'
|
import { HeatBadge } from '../shared'
|
||||||
import type { PipelineItem } from '../../domain/pipeline'
|
import type { PipelineItem } from '../../domain/pipeline'
|
||||||
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
import { detailPath } from './pipelineUtils'
|
import { detailPath } from './pipelineUtils'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
|
||||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ export function DraggableCard({
|
|||||||
onChatClick?: (e: React.MouseEvent) => void
|
onChatClick?: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
||||||
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
|
||||||
const path = detailPath(item)
|
const path = detailPath(item)
|
||||||
|
|
||||||
const style = !isDragOverlay ? {
|
const style = !isDragOverlay ? {
|
||||||
@@ -87,7 +89,7 @@ export function DraggableCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip title="Objekt öffnen">
|
<Tooltip title="Match öffnen">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||||
@@ -135,6 +137,57 @@ export function DraggableCard({
|
|||||||
{item.notes}
|
{item.notes}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 6: Anfrage / Konversation */}
|
||||||
|
{!isDragOverlay && (
|
||||||
|
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
|
||||||
|
{item.stage !== 'SAVED' || item.inquiryId ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<MessagesSquare size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
borderColor: '#1e3a5f', color: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zur Konversation
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Send size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: item.title,
|
||||||
|
location: item.propertyAddress ?? item.location,
|
||||||
|
areaLabel: item.areaLabel,
|
||||||
|
rentLabel: item.rentLabel,
|
||||||
|
matchScore: item.matchScore,
|
||||||
|
pipelineItemId: item.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function DroppableColumn({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
|
onCardChatClick,
|
||||||
isOver,
|
isOver,
|
||||||
}: {
|
}: {
|
||||||
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
||||||
@@ -18,6 +19,7 @@ export function DroppableColumn({
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (item: PipelineItem) => void
|
onSelect: (item: PipelineItem) => void
|
||||||
onChatClick: (inquiryId: string) => void
|
onChatClick: (inquiryId: string) => void
|
||||||
|
onCardChatClick?: (item: PipelineItem) => void
|
||||||
isOver: boolean
|
isOver: boolean
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef } = useDroppable({ id: stage.key })
|
const { setNodeRef } = useDroppable({ id: stage.key })
|
||||||
@@ -43,7 +45,13 @@ export function DroppableColumn({
|
|||||||
item={item}
|
item={item}
|
||||||
isSelected={item.id === selectedId}
|
isSelected={item.id === selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
onChatClick={
|
||||||
|
item.inquiryId
|
||||||
|
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
|
||||||
|
: onCardChatClick
|
||||||
|
? (e) => { e.stopPropagation(); onCardChatClick(item) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { STAGES, NEXT_STAGE, MOCK_DOCS, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
|
export { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
|
||||||
|
export { DraggableCard } from './PipelineCard'
|
||||||
|
export { DroppableColumn } from './PipelineColumn'
|
||||||
|
export { DetailPanel } from './PipelineDetailPanel'
|
||||||
@@ -4,9 +4,8 @@ export { RESULT_TYPE_META } from '../../lib/ds'
|
|||||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const STAGES = [
|
export const STAGES = [
|
||||||
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
|
{ key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' },
|
||||||
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
|
{ key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||||
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
|
|
||||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||||
@@ -14,9 +13,8 @@ export const STAGES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||||
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
|
SAVED: { key: 'CONTACTED', label: 'Anfrage senden' },
|
||||||
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
|
CONTACTED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||||
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
|
|
||||||
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
||||||
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type { PipelineItem } from '../../domain/pipeline'
|
|||||||
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
||||||
|
|
||||||
export function detailPath(item: PipelineItem): string | null {
|
export function detailPath(item: PipelineItem): string | null {
|
||||||
// propertyId is always stable across sessions — prefer it
|
// Both 'match-XXX' (static mock) and 'm__...' (deterministic dynamic) IDs are stable across sessions
|
||||||
|
const isStableMatchId = item.matchId?.startsWith('match-') || item.matchId?.startsWith('m__')
|
||||||
|
if (isStableMatchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
||||||
// matchId / UUID only works in the same session (matchStore is ephemeral)
|
|
||||||
if (item.matchId) return `/demand/results/${item.matchId}`
|
if (item.matchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router'
|
|||||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
|
import { resolveImageLabel } from '../../lib/propertyImageResolver'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
@@ -97,6 +98,17 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
? result.property.location.city
|
? result.property.location.city
|
||||||
: result.signal.locationHint ?? undefined
|
: result.signal.locationHint ?? undefined
|
||||||
|
|
||||||
|
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? resolveImageLabel({
|
||||||
|
assetType: result.property.assetType,
|
||||||
|
city: result.property.location.city,
|
||||||
|
district: result.property.location.district,
|
||||||
|
prestige: result.property.softFactors?.prestige,
|
||||||
|
floorLevel: result.property.floorLevel,
|
||||||
|
propertyId: result.property.id,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntelligenceMatchCard
|
<IntelligenceMatchCard
|
||||||
vm={vm}
|
vm={vm}
|
||||||
@@ -104,9 +116,25 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
lat={lat}
|
lat={lat}
|
||||||
lng={lng}
|
lng={lng}
|
||||||
cityLabel={cityLabel}
|
cityLabel={cityLabel}
|
||||||
|
overlayTypeLabel={overlayLabels?.type}
|
||||||
|
overlayLocationLabel={overlayLabels?.location}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MatchCardCompact vm={vm} />
|
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? result.property.images?.[0]
|
||||||
|
: undefined
|
||||||
|
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? resolveImageLabel({
|
||||||
|
assetType: result.property.assetType,
|
||||||
|
city: result.property.location.city,
|
||||||
|
district: result.property.location.district,
|
||||||
|
prestige: result.property.softFactors?.prestige,
|
||||||
|
floorLevel: result.property.floorLevel,
|
||||||
|
propertyId: result.property.id,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ interface Props {
|
|||||||
address?: string
|
address?: string
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
height?: number
|
height?: number
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: replace placeholder with Google Maps Static API:
|
|
||||||
// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY
|
|
||||||
|
|
||||||
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
||||||
if (lat != null && lng != null) {
|
if (lat != null && lng != null) {
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||||
@@ -24,7 +23,7 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
|||||||
return 'https://www.google.com/maps'
|
return 'https://www.google.com/maps'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180 }: Props) {
|
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
const mapsUrl = buildMapsUrl(lat, lng, address)
|
const mapsUrl = buildMapsUrl(lat, lng, address)
|
||||||
const showImage = !!imageUrl && !imgError
|
const showImage = !!imageUrl && !imgError
|
||||||
@@ -60,6 +59,42 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom-left overlay: type + location */}
|
||||||
|
{(overlayTypeLabel || overlayLocationLabel) && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.58) 0%, rgba(0,0,0,0) 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.68rem', fontWeight: 700, color: 'white',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(4px)',
|
||||||
|
px: 0.75, py: 0.25, borderRadius: 0.75, lineHeight: 1.4,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{overlayLocationLabel && (
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.4 }}>
|
||||||
|
{overlayLocationLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Google Maps button */}
|
{/* Google Maps button */}
|
||||||
<Tooltip title="In Google Maps öffnen" placement="top">
|
<Tooltip title="In Google Maps öffnen" placement="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function ShortlistDetail({ shortlist }: Props) {
|
|||||||
matchId: item.resultId,
|
matchId: item.resultId,
|
||||||
needId: '',
|
needId: '',
|
||||||
matchScore: item.matchScore,
|
matchScore: item.matchScore,
|
||||||
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK',
|
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||||
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
|
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
|
||||||
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
|
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type { ShortlistItem } from '../../domain/shortlist'
|
|||||||
|
|
||||||
const RESULT_TYPE_LABEL: Record<string, string> = {
|
const RESULT_TYPE_LABEL: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: 'Portfolio',
|
VERIFIED_PORTFOLIO: 'Portfolio',
|
||||||
EXTERNAL_MARKET: 'Direkt',
|
|
||||||
MAISON_WORK: 'Maison Work',
|
MAISON_WORK: 'Maison Work',
|
||||||
FUTURE_AVAILABILITY: 'Signal',
|
FUTURE_AVAILABILITY: 'Signal',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp
|
|||||||
onClose={ready ? onClose : undefined}
|
onClose={ready ? onClose : undefined}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
|
slotProps={{ paper: { sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } } }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||||
|
import { getKpisForRadius } from '../../domain/marketReport'
|
||||||
|
import type { MarketKpis } from '../../domain/marketReport'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
baseKpis: MarketKpis
|
||||||
|
assetTypeLabel: string
|
||||||
|
radius: number
|
||||||
|
onRadiusChange: (r: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RADII = [5, 10, 15, 20]
|
||||||
|
|
||||||
|
function vacancyColor(pct: number): string {
|
||||||
|
if (pct < 4) return '#1a7a4a'
|
||||||
|
if (pct < 8) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function vacancyLabel(pct: number): string {
|
||||||
|
if (pct < 4) return 'Engpassmarkt'
|
||||||
|
if (pct < 8) return 'Ausgewogen'
|
||||||
|
return 'Überangebot'
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchesColor(count: number): string {
|
||||||
|
if (count >= 15) return '#1a7a4a'
|
||||||
|
if (count >= 7) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchesLabel(count: number): string {
|
||||||
|
if (count >= 15) return 'Hohe Nachfrage'
|
||||||
|
if (count >= 7) return 'Moderate Nachfrage'
|
||||||
|
return 'Geringe Nachfrage'
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationColor(months: number): string {
|
||||||
|
if (months < 4) return '#1a7a4a'
|
||||||
|
if (months < 7) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(months: number): string {
|
||||||
|
if (months < 4) return 'Schnell absorbiert'
|
||||||
|
if (months < 7) return 'Moderat'
|
||||||
|
return 'Träger Markt'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioColor(ratio: number): string {
|
||||||
|
if (ratio >= 2) return '#1a7a4a'
|
||||||
|
if (ratio >= 1) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioLabel(ratio: number): string {
|
||||||
|
if (ratio >= 2) return 'Nachfrageüberhang'
|
||||||
|
if (ratio >= 1) return 'Leicht ausgeglichen'
|
||||||
|
return 'Angebotsüberhang'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
badge: string
|
||||||
|
badgeColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, badge, badgeColor }: KpiCardProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: 1.5,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 500, fontSize: '0.7rem', lineHeight: 1.2 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, color: '#0f172a', lineHeight: 1 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 0.75,
|
||||||
|
bgcolor: `${badgeColor}18`,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
mt: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: badgeColor, lineHeight: 1.4 }}>
|
||||||
|
{badge}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketKpiPanel({ baseKpis, assetTypeLabel, radius, onRadiusChange }: Props) {
|
||||||
|
const kpis = useMemo(
|
||||||
|
() => radius === 5 ? baseKpis : getKpisForRadius(baseKpis, radius),
|
||||||
|
[baseKpis, radius]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 2.5,
|
||||||
|
p: 1.75,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.8125rem' }}>
|
||||||
|
Marktumfeld — {assetTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||||
|
Vergleichbare Flächen im Umkreis
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={radius}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, v) => v !== null && onRadiusChange(v)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
'& .MuiToggleButton-root': {
|
||||||
|
py: 0.25,
|
||||||
|
px: 0.875,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
color: '#64748b',
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: '#1e3a5f',
|
||||||
|
color: '#ffffff',
|
||||||
|
borderColor: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#1e3a5f' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RADII.map(r => (
|
||||||
|
<ToggleButton key={r} value={r}>
|
||||||
|
{r} km
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<KpiCard
|
||||||
|
label="Leerstandsquote"
|
||||||
|
value={`${kpis.vacancyRatePct.toFixed(1)} %`}
|
||||||
|
badge={vacancyLabel(kpis.vacancyRatePct)}
|
||||||
|
badgeColor={vacancyColor(kpis.vacancyRatePct)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Aktive Suchanfragen"
|
||||||
|
value={String(kpis.activePlatformSearches)}
|
||||||
|
badge={searchesLabel(kpis.activePlatformSearches)}
|
||||||
|
badgeColor={searchesColor(kpis.activePlatformSearches)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Ø Leerstandsdauer"
|
||||||
|
value={`${kpis.avgVacancyMonths.toFixed(1)} Mo.`}
|
||||||
|
badge={durationLabel(kpis.avgVacancyMonths)}
|
||||||
|
badgeColor={durationColor(kpis.avgVacancyMonths)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Nachfrage / Angebot"
|
||||||
|
value={`${kpis.demandSupplyRatio.toFixed(1)}×`}
|
||||||
|
badge={ratioLabel(kpis.demandSupplyRatio)}
|
||||||
|
badgeColor={ratioColor(kpis.demandSupplyRatio)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', mt: 1, display: 'block' }}>
|
||||||
|
Basis: {kpis.comparableListings} vergleichbare {assetTypeLabel}-Flächen im {radius}-km-Radius · KI-gestützte Schätzung
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Box, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
||||||
|
import { ExternalLink, FileText } from 'lucide-react'
|
||||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||||
import { PropertyMap } from '../shared'
|
import { PropertyMap } from '../shared'
|
||||||
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||||
@@ -97,8 +98,32 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
|||||||
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
|
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, mb: 0.5 }}>
|
||||||
|
<FileText size={13} color="#64748b" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
Mietvertrag
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Vertrag URL"
|
||||||
|
size="small"
|
||||||
|
value={draft.leaseContractUrl ?? p.leaseContractUrl ?? ''}
|
||||||
|
onChange={e => onDraftChange({ ...draft, leaseContractUrl: e.target.value })}
|
||||||
|
placeholder="https://…"
|
||||||
|
slotProps={{ input: { startAdornment: <ExternalLink size={12} style={{ marginRight: 6, color: '#94a3b8', flexShrink: 0 }} /> } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Bezeichnung (optional)"
|
||||||
|
size="small"
|
||||||
|
value={draft.leaseContractName ?? p.leaseContractName ?? ''}
|
||||||
|
onChange={e => onDraftChange({ ...draft, leaseContractName: e.target.value })}
|
||||||
|
placeholder="z.B. Mietvertrag 2021–2026"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<FieldGrid>
|
<FieldGrid>
|
||||||
<Field label="Aktueller Mieter" value={p.currentTenant} />
|
<Field label="Aktueller Mieter" value={p.currentTenant} />
|
||||||
<Field label="Mietlaufzeit" value={p.leaseTerm} />
|
<Field label="Mietlaufzeit" value={p.leaseTerm} />
|
||||||
@@ -109,6 +134,39 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
|||||||
<Field label="Importiert aus" value={p.importedFrom} />
|
<Field label="Importiert aus" value={p.importedFrom} />
|
||||||
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
||||||
</FieldGrid>
|
</FieldGrid>
|
||||||
|
|
||||||
|
{/* Mietvertrag */}
|
||||||
|
<Box sx={{ mt: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: p.leaseContractUrl ? '#f8fafc' : '#fafafa' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: p.leaseContractUrl ? 0.75 : 0 }}>
|
||||||
|
<FileText size={13} color={p.leaseContractUrl ? '#1e3a5f' : '#94a3b8'} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: p.leaseContractUrl ? '#1e3a5f' : '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
Mietvertrag
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{p.leaseContractUrl ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500 }} noWrap>
|
||||||
|
{p.leaseContractName ?? 'Mietvertrag'}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<ExternalLink size={11} />}
|
||||||
|
href={p.leaseContractUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.25, px: 1, borderColor: '#cbd5e1', color: '#1e3a5f', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||||
|
Noch kein Mietvertrag hinterlegt — im Bearbeitungsmodus hinzufügen.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Floor / unit structure */}
|
{/* Floor / unit structure */}
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
|
|||||||
interface PropertyDetailViewProps {
|
interface PropertyDetailViewProps {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
|
hideTabs?: Array<'Matchability' | 'Marktsignale'>
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
|
const ALL_TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
|
||||||
|
|
||||||
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
|
export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: PropertyDetailViewProps) {
|
||||||
|
const TABS = ALL_TABS.filter(t => !hideTabs.includes(t as 'Matchability' | 'Marktsignale'))
|
||||||
const [tab, setTab] = useState(0)
|
const [tab, setTab] = useState(0)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [draft, setDraft] = useState<UpdatePropertyInput>({})
|
const [draft, setDraft] = useState<UpdatePropertyInput>({})
|
||||||
@@ -175,8 +177,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', p: tab === 0 ? 2.5 : 0 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', p: TABS[tab] === 'Übersicht' ? 2.5 : 0 }}>
|
||||||
{tab === 0 && (
|
{TABS[tab] === 'Übersicht' && (
|
||||||
<PropertyDetailOverview
|
<PropertyDetailOverview
|
||||||
p={property}
|
p={property}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -184,8 +186,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
|||||||
onDraftChange={setDraft}
|
onDraftChange={setDraft}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 1 && <MatchabilityTabPanel propertyId={propertyId} />}
|
{TABS[tab] === 'Matchability' && <MatchabilityTabPanel propertyId={propertyId} />}
|
||||||
{tab === 2 && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
{TABS[tab] === 'Marktsignale' && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
|
||||||
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
@@ -9,133 +9,176 @@ interface Props {
|
|||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function availabilityBadgeColor(status: string): string {
|
function availabilityColor(status: string) {
|
||||||
if (status === 'AVAILABLE_NOW') return '#1a7a4a'
|
if (status === 'AVAILABLE_NOW') return { bg: '#dcfce7', text: '#15803d' }
|
||||||
if (status === 'AVAILABLE_SOON') return '#d97706'
|
if (status === 'AVAILABLE_SOON') return { bg: '#fef9c3', text: '#a16207' }
|
||||||
return '#64748b'
|
return { bg: '#f1f5f9', text: '#64748b' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
||||||
const confPct = Math.round(p.confidenceScore * 100)
|
const confPct = Math.round(p.confidenceScore * 100)
|
||||||
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
|
const confColor = p.confidenceScore >= 0.75 ? '#15803d' : p.confidenceScore >= 0.55 ? '#d97706' : '#dc2626'
|
||||||
|
const annualRent = Math.round(p.areaSqm * p.rentPricePerSqm / 1000)
|
||||||
|
const avail = availabilityColor(p.availabilityStatus)
|
||||||
|
const assetColor = getAssetTypeColor(p.assetType)
|
||||||
|
const hasImage = !!p.images?.[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: '1px solid #e8d5c4',
|
border: '1px solid #e2e8f0',
|
||||||
background: 'linear-gradient(160deg,#fdf8f3,#faf0e6)',
|
bgcolor: 'white',
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
transition: 'box-shadow 0.15s, transform 0.12s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: '0 6px 24px rgba(0,0,0,0.10)',
|
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||||
transform: 'translateY(-1px)',
|
transform: 'translateY(-2px)',
|
||||||
|
borderColor: '#cbd5e1',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onClick={() => onSelect(p.id)}
|
|
||||||
>
|
>
|
||||||
{/* Image zone */}
|
{/* ── Image / header ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative', height: 120, flexShrink: 0, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||||
<LocationPreview
|
{hasImage ? (
|
||||||
imageUrl={p.images?.[0]}
|
<Box
|
||||||
lat={p.location.coordinates?.lat}
|
component="img"
|
||||||
lng={p.location.coordinates?.lng}
|
src={p.images![0]}
|
||||||
address={`${p.address.street} ${p.address.houseNumber}, ${p.address.city}`}
|
alt={p.title}
|
||||||
cityLabel={p.location.city}
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
height={175}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
{/* Availability badge */}
|
<Box
|
||||||
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
|
|
||||||
<Chip
|
|
||||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: availabilityBadgeColor(p.availabilityStatus),
|
width: '100%', height: '100%',
|
||||||
color: 'white',
|
background: `linear-gradient(135deg, ${assetColor}22 0%, ${assetColor}44 100%)`,
|
||||||
fontWeight: 700,
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: 10,
|
|
||||||
height: 20,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Typography sx={{ fontSize: '2.5rem', opacity: 0.3 }}>🏢</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Asset type chip */}
|
{/* Overlay gradient */}
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
<Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.45) 0%, transparent 55%)' }} />
|
||||||
|
|
||||||
|
{/* Top badges */}
|
||||||
|
<Box sx={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 0.5 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={getAssetTypeLabel(p.assetType)}
|
label={getAssetTypeLabel(p.assetType)}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: assetColor, color: 'white', borderRadius: 1 }}
|
||||||
bgcolor: getAssetTypeColor(p.assetType),
|
/>
|
||||||
color: 'white',
|
<Chip
|
||||||
fontWeight: 600,
|
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||||
fontSize: 10,
|
size="small"
|
||||||
height: 20,
|
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: avail.bg, color: avail.text, borderRadius: 1 }}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom: annual rent on image */}
|
||||||
|
<Box sx={{ position: 'absolute', bottom: 8, left: 10, right: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.65rem', lineHeight: 1 }}>Jahresmiete ca.</Typography>
|
||||||
|
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1.05rem', lineHeight: 1.2 }}>
|
||||||
|
CHF {annualRent}k
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||||
|
{p.rentPricePerSqm} /m²/J
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Card body */}
|
{/* ── Card body ── */}
|
||||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
|
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
|
||||||
|
{/* Title + location */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
|
||||||
{p.title}
|
{p.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, mb: 1.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.3 }}>
|
||||||
|
<MapPin size={11} color="#94a3b8" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem' }}>
|
||||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Key specs */}
|
{/* Key metrics row */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<Chip label={`${p.areaSqm} m²`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||||
<Chip label={`CHF ${p.rentPricePerSqm}/m²/Jahr`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||||
{p.contractDurationMonths && (
|
<Maximize2 size={10} color="#64748b" />
|
||||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Fläche</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||||
|
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{p.availabilityDate && (
|
||||||
|
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||||
|
<Calendar size={10} color="#64748b" />
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Ab</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||||
|
{new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{p.softFactors?.prestige != null && (
|
||||||
|
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||||
|
<TrendingUp size={10} color="#64748b" />
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Prestige</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||||
|
{p.softFactors.prestige}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Soft factors */}
|
{/* Soft factor chips */}
|
||||||
{p.softFactors && (
|
{p.softFactors && (
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
{p.softFactors.prestige != null && (
|
{p.softFactors.publicTransportMinutes != null && (
|
||||||
<Chip label={`Prestige ${p.softFactors.prestige}`} size="small"
|
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
|
||||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
|
||||||
)}
|
)}
|
||||||
{p.softFactors.accessibility != null && (
|
{p.hardFacts?.fitOut && (
|
||||||
<Chip label={`ÖV ${p.softFactors.publicTransportMinutes ?? p.softFactors.accessibility}min`} size="small"
|
<Chip label={p.hardFacts.fitOut} size="small"
|
||||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9' }} />
|
||||||
|
)}
|
||||||
|
{p.contractDurationMonths && (
|
||||||
|
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569' }} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confidence */}
|
{/* Confidence bar */}
|
||||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
<Box sx={{ mt: 'auto' }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
<Typography variant="caption" color="text.secondary">Datenkonfidenz</Typography>
|
<Typography sx={{ fontSize: '0.67rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Datenkonfidenz</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={confPct}
|
value={confPct}
|
||||||
sx={{
|
sx={{
|
||||||
height: 5, borderRadius: 3, bgcolor: '#e2e8f0',
|
height: 4, borderRadius: 2, bgcolor: '#f1f5f9',
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: confColor },
|
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 2 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
onClick={e => { e.stopPropagation(); onSelect(p.id) }}
|
|
||||||
sx={{ mt: 1.25, alignSelf: 'flex-end', textTransform: 'none', fontSize: '0.75rem', color: '#1e3a5f', px: 0 }}
|
|
||||||
>
|
|
||||||
Details anzeigen →
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,21 +5,33 @@ import { useMarketReport } from '../../hooks/useMarketReport'
|
|||||||
import type { SignalCategory } from '../../domain/marketReport'
|
import type { SignalCategory } from '../../domain/marketReport'
|
||||||
import { SignalCard } from './SignalCard'
|
import { SignalCard } from './SignalCard'
|
||||||
import { BerichtDialog } from './BerichtDialog'
|
import { BerichtDialog } from './BerichtDialog'
|
||||||
|
import { MarketKpiPanel } from './MarketKpiPanel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
const STATIC_SECTIONS: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||||
{ category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
|
{ category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
|
||||||
{ category: 'demand', label: 'Nachfrage 5 km', icon: <TrendingUp size={16} />, color: '#1a7a4a' },
|
|
||||||
{ category: 'supply', label: 'Angebot 5 km', icon: <BarChart2 size={16} />, color: '#b45309' },
|
|
||||||
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
|
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||||
const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
|
const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [radius, setRadius] = useState(5)
|
||||||
|
|
||||||
|
const dynamicSections: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||||
|
{ category: 'demand', label: `Nachfrage ${radius} km`, icon: <TrendingUp size={16} />, color: '#1a7a4a' },
|
||||||
|
{ category: 'supply', label: `Angebot ${radius} km`, icon: <BarChart2 size={16} />, color: '#b45309' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sectionConfig = [
|
||||||
|
STATIC_SECTIONS[0],
|
||||||
|
dynamicSections[0],
|
||||||
|
dynamicSections[1],
|
||||||
|
STATIC_SECTIONS[1],
|
||||||
|
]
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -58,7 +70,17 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
|||||||
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
|
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
SECTION_CONFIG.map(({ category, label, icon, color }) => {
|
<>
|
||||||
|
{report.kpis && (
|
||||||
|
<MarketKpiPanel
|
||||||
|
baseKpis={report.kpis}
|
||||||
|
assetTypeLabel={report.assetTypeLabel}
|
||||||
|
radius={radius}
|
||||||
|
onRadiusChange={setRadius}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sectionConfig.map(({ category, label, icon, color }) => {
|
||||||
const signals = report.signals.filter(s => s.category === category)
|
const signals = report.signals.filter(s => s.category === category)
|
||||||
if (signals.length === 0) return null
|
if (signals.length === 0) return null
|
||||||
return (
|
return (
|
||||||
@@ -77,7 +99,8 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
|||||||
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dialogOpen && (
|
{dialogOpen && (
|
||||||
|
|||||||
@@ -12,18 +12,20 @@ import {
|
|||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { X, MapPin, Calendar, DollarSign, Eye, Activity } from 'lucide-react'
|
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||||
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||||
import { ReminderStatus } from '../../domain/reminder'
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, ACTION_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
||||||
|
|
||||||
export function ReminderDetailDrawer() {
|
export function ReminderDetailDrawer() {
|
||||||
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
||||||
const { data: reminder } = useReminder(selectedId ?? '')
|
const { data: reminder } = useReminder(selectedId ?? '')
|
||||||
|
const { data: reminderProperty } = usePropertyById(reminder?.propertyId ?? '')
|
||||||
const complete = useCompleteReminder()
|
const complete = useCompleteReminder()
|
||||||
const dismiss = useDismissReminder()
|
const dismiss = useDismissReminder()
|
||||||
const snooze = useSnoozeReminder()
|
const snooze = useSnoozeReminder()
|
||||||
@@ -101,6 +103,27 @@ export function ReminderDetailDrawer() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
|
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||||
|
{reminderProperty?.leaseContractUrl ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||||
|
<FileText size={12} color="#1e3a5f" />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<ExternalLink size={10} />}
|
||||||
|
href={reminderProperty.leaseContractUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#1e3a5f' }}
|
||||||
|
>
|
||||||
|
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||||
|
<FileText size={12} color="#cbd5e1" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useCallback } from 'react'
|
import { useMemo, useCallback } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
import { useReminders } from '../../hooks/useReminders'
|
import { useReminders } from '../../hooks/useReminders'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
@@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard'
|
|||||||
import { ReminderSkeleton } from './ReminderSkeleton'
|
import { ReminderSkeleton } from './ReminderSkeleton'
|
||||||
import { ReminderEmptyState } from './ReminderEmptyState'
|
import { ReminderEmptyState } from './ReminderEmptyState'
|
||||||
import type { Reminder } from '../../domain/reminder'
|
import type { Reminder } from '../../domain/reminder'
|
||||||
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
|
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||||
|
|
||||||
const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
const MOCK_TODAY = new Date('2026-05-20')
|
||||||
|
|
||||||
|
function getHorizon(dueDate: string): FilterHorizon {
|
||||||
|
const days = Math.ceil((new Date(dueDate).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
if (days <= 0) return 'OVERDUE'
|
||||||
|
if (days <= 7) return 'THIS_WEEK'
|
||||||
|
if (days <= 30) return 'THIS_MONTH'
|
||||||
|
return 'LATER'
|
||||||
|
}
|
||||||
|
|
||||||
|
const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: string; border: string }[] = [
|
||||||
|
{ key: 'OVERDUE', label: 'Überfällig', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||||
|
{ key: 'THIS_WEEK', label: 'Diese Woche', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
|
||||||
|
{ key: 'THIS_MONTH', label: 'Dieser Monat', color: '#0369a1', bg: '#f0f9ff', border: '#bae6fd' },
|
||||||
|
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e2e8f0' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
||||||
|
|
||||||
function applyFilters(
|
function applyFilters(
|
||||||
reminders: Reminder[],
|
reminders: Reminder[],
|
||||||
filterType: string,
|
filterType: string,
|
||||||
filterStatus: string,
|
filterStatus: string,
|
||||||
filterPriority: string,
|
filterHorizon: string,
|
||||||
searchQuery: string,
|
searchQuery: string,
|
||||||
): Reminder[] {
|
): Reminder[] {
|
||||||
return reminders.filter(r => {
|
return reminders.filter(r => {
|
||||||
|
if (filterStatus === 'ACTIVE') {
|
||||||
|
if (r.status !== ReminderStatus.ACTIVE && r.status !== ReminderStatus.SNOOZED) return false
|
||||||
|
} else if (filterStatus !== 'ALL') {
|
||||||
|
if (r.status !== filterStatus) return false
|
||||||
|
}
|
||||||
if (filterType !== 'ALL' && r.type !== filterType) return false
|
if (filterType !== 'ALL' && r.type !== filterType) return false
|
||||||
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false
|
if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false
|
||||||
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase()
|
const q = searchQuery.toLowerCase()
|
||||||
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||||
if (!haystack.includes(q)) return false
|
if (!hay.includes(q)) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -34,38 +57,60 @@ function applyFilters(
|
|||||||
export function ReminderFeed() {
|
export function ReminderFeed() {
|
||||||
const { data, isLoading } = useReminders()
|
const { data, isLoading } = useReminders()
|
||||||
const {
|
const {
|
||||||
filterType, filterStatus, filterPriority, searchQuery, viewMode,
|
filterType, filterStatus, filterHorizon, searchQuery, viewMode,
|
||||||
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery,
|
setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery,
|
||||||
} = useReminderStore(useShallow(s => ({
|
} = useReminderStore(useShallow(s => ({
|
||||||
filterType: s.filterType, filterStatus: s.filterStatus,
|
filterType: s.filterType, filterStatus: s.filterStatus,
|
||||||
filterPriority: s.filterPriority, searchQuery: s.searchQuery,
|
filterHorizon: s.filterHorizon, searchQuery: s.searchQuery,
|
||||||
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
||||||
setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority,
|
setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon,
|
||||||
setSearchQuery: s.setSearchQuery,
|
setSearchQuery: s.setSearchQuery,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
const reminders = data ?? []
|
const reminders = data ?? []
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
|
() => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery),
|
||||||
[reminders, filterType, filterStatus, filterPriority, searchQuery],
|
[reminders, filterType, filterStatus, filterHorizon, searchQuery],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map: Record<string, Reminder[]> = {
|
||||||
|
OVERDUE: [], THIS_WEEK: [], THIS_MONTH: [], LATER: [],
|
||||||
|
}
|
||||||
|
filtered.forEach(r => map[getHorizon(r.dueDate)].push(r))
|
||||||
|
return map
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setFilterType('ALL')
|
setFilterType('ALL')
|
||||||
setFilterStatus('ALL')
|
setFilterStatus('ACTIVE')
|
||||||
setFilterPriority('ALL')
|
setFilterHorizon('ALL')
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
|
}, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery])
|
||||||
|
|
||||||
if (isLoading) return <ReminderSkeleton />
|
if (isLoading) return <ReminderSkeleton />
|
||||||
|
if (filtered.length === 0) return <ReminderEmptyState onReset={resetFilters} />
|
||||||
if (filtered.length === 0) {
|
|
||||||
return <ReminderEmptyState onReset={resetFilters} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewMode === 'card') {
|
if (viewMode === 'card') {
|
||||||
return (
|
return (
|
||||||
|
<Box className="flex flex-col gap-4">
|
||||||
|
{HORIZON_CONFIG.map(({ key, label, color }) => {
|
||||||
|
const group = grouped[key]
|
||||||
|
if (!group || group.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, px: 0.5 }}>
|
||||||
|
<Box sx={{ width: 3, height: 16, borderRadius: 1, bgcolor: color, flexShrink: 0 }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color, fontSize: '0.8125rem' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={group.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', bgcolor: color, color: 'white', ml: 0.25 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -73,20 +118,22 @@ export function ReminderFeed() {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filtered.map(r => (
|
{group.map(r => <ReminderCard key={r.id} reminder={r} />)}
|
||||||
<ReminderCard key={r.id} reminder={r} />
|
</Box>
|
||||||
))}
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: LIST_HEADER_COLS,
|
gridTemplateColumns: LIST_COLS,
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1,
|
py: 1,
|
||||||
@@ -101,9 +148,43 @@ export function ReminderFeed() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{filtered.map(r => (
|
{/* Grouped rows */}
|
||||||
<ReminderListRow key={r.id} reminder={r} />
|
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
|
||||||
))}
|
const group = grouped[key]
|
||||||
|
if (!group || group.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
{/* Section divider */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 0.625,
|
||||||
|
bgcolor: bg,
|
||||||
|
borderTop: `1px solid ${border}`,
|
||||||
|
borderBottom: `1px solid ${border}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: 0.6, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={group.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 16, fontSize: '0.6rem', bgcolor: color, color: 'white', px: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{group.map(r => <ReminderListRow key={r.id} reminder={r} />)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography } from '@mui/material'
|
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography, Select, MenuItem, FormControl, FormControlLabel, Switch } from '@mui/material'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
import { ReminderType } from '../../domain/reminder'
|
||||||
import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder'
|
import type { ReminderType as ReminderTypeType } from '../../domain/reminder'
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||||
LEASE_EXPIRY: 'Mietablauf',
|
LEASE_EXPIRY: 'Mietablauf',
|
||||||
@@ -16,38 +16,23 @@ const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
|||||||
CUSTOM: 'Individuell',
|
CUSTOM: 'Individuell',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<ReminderStatusType, string> = {
|
|
||||||
ACTIVE: 'Aktiv',
|
|
||||||
SNOOZED: 'Schlummern',
|
|
||||||
COMPLETED: 'Erledigt',
|
|
||||||
DISMISSED: 'Verworfen',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<ReminderPriorityType, string> = {
|
|
||||||
URGENT: 'Dringend',
|
|
||||||
HIGH: 'Hoch',
|
|
||||||
MEDIUM: 'Mittel',
|
|
||||||
LOW: 'Niedrig',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReminderFilterBar() {
|
export function ReminderFilterBar() {
|
||||||
const {
|
const {
|
||||||
filterType, setFilterType,
|
filterType, setFilterType,
|
||||||
filterStatus, setFilterStatus,
|
filterStatus, setFilterStatus,
|
||||||
filterPriority, setFilterPriority,
|
|
||||||
searchQuery, setSearchQuery,
|
searchQuery, setSearchQuery,
|
||||||
viewMode, setViewMode,
|
viewMode, setViewMode,
|
||||||
} = useReminderStore(useShallow(s => ({
|
} = useReminderStore(useShallow(s => ({
|
||||||
filterType: s.filterType, setFilterType: s.setFilterType,
|
filterType: s.filterType, setFilterType: s.setFilterType,
|
||||||
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
||||||
filterPriority: s.filterPriority, setFilterPriority: s.setFilterPriority,
|
|
||||||
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
||||||
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
|
const showArchive = filterStatus === 'ALL'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-3">
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
<Box className="flex items-center gap-3 flex-wrap">
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
@@ -63,7 +48,43 @@ export function ReminderFilterBar() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
sx={{ width: 200, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Type selector */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>Typ:</Typography>
|
||||||
|
<FormControl size="small">
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onChange={e => setFilterType(e.target.value as ReminderTypeType | 'ALL')}
|
||||||
|
sx={{ fontSize: '0.8125rem', minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="ALL" sx={{ fontSize: '0.8125rem' }}>Alle Typen</MenuItem>
|
||||||
|
{Object.values(ReminderType).map(t => (
|
||||||
|
<MenuItem key={t} value={t} sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{TYPE_LABELS[t]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Archive toggle */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={showArchive}
|
||||||
|
onChange={e => setFilterStatus(e.target.checked ? 'ALL' : 'ACTIVE')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Abgeschlossene anzeigen
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ m: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* View mode */}
|
{/* View mode */}
|
||||||
@@ -80,64 +101,5 @@ export function ReminderFilterBar() {
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="flex flex-wrap gap-3">
|
|
||||||
{/* Type filter */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Typ:</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterType}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setFilterType(v)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
|
||||||
{Object.values(ReminderType).map(t => (
|
|
||||||
<ToggleButton key={t} value={t} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
|
||||||
{TYPE_LABELS[t]}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="flex flex-wrap gap-3">
|
|
||||||
{/* Status filter */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Status:</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterStatus}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setFilterStatus(v)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
|
||||||
{Object.values(ReminderStatus).map(s => (
|
|
||||||
<ToggleButton key={s} value={s} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
|
||||||
{STATUS_LABELS[s]}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Priority filter */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Priorität:</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterPriority}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setFilterPriority(v)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
|
||||||
{Object.values(ReminderPriority).map(p => (
|
|
||||||
<ToggleButton key={p} value={p} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
|
||||||
{PRIORITY_LABELS[p]}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,46 @@
|
|||||||
import { Box, Paper, Typography, Skeleton } from '@mui/material'
|
import { Box, Typography, Skeleton } from '@mui/material'
|
||||||
import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react'
|
import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderInsights } from '../../hooks/useReminders'
|
import { useReminderInsights } from '../../hooks/useReminders'
|
||||||
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
|
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||||
|
import { ReminderType } from '../../domain/reminder'
|
||||||
|
|
||||||
interface KpiItemProps {
|
interface KpiCardProps {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
label: string
|
label: string
|
||||||
value: number | string
|
value: number | string
|
||||||
color: string
|
color: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Box
|
||||||
variant="outlined"
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
borderColor: '#e2e8f0',
|
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: active ? `${color}10` : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: `${color}0d`,
|
||||||
|
borderColor: color,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 36,
|
width: 34,
|
||||||
height: 36,
|
height: 34,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
bgcolor: `${color}18`,
|
bgcolor: `${color}18`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -41,16 +55,42 @@ function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
|||||||
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap', fontSize: '0.7rem' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReminderKpiBar() {
|
export function ReminderKpiBar() {
|
||||||
const { data, isLoading } = useReminderInsights()
|
const { data, isLoading } = useReminderInsights()
|
||||||
|
const { filterHorizon, setFilterHorizon, filterType, setFilterType } = useReminderStore(
|
||||||
|
useShallow(s => ({
|
||||||
|
filterHorizon: s.filterHorizon,
|
||||||
|
setFilterHorizon: s.setFilterHorizon,
|
||||||
|
filterType: s.filterType,
|
||||||
|
setFilterType: s.setFilterType,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleHorizon(h: FilterHorizon) {
|
||||||
|
if (filterHorizon === h) {
|
||||||
|
setFilterHorizon('ALL')
|
||||||
|
} else {
|
||||||
|
setFilterHorizon(h)
|
||||||
|
setFilterType('ALL') // clear type filter so count matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreMarket() {
|
||||||
|
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
|
||||||
|
setFilterType('ALL')
|
||||||
|
} else {
|
||||||
|
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
|
||||||
|
setFilterHorizon('ALL') // clear horizon filter so count matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -62,33 +102,41 @@ export function ReminderKpiBar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const insights = data ?? { urgentCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex gap-3">
|
<Box className="flex gap-3">
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<AlertTriangle size={18} color="#dc2626" />}
|
icon={<AlertOctagon size={18} color="#dc2626" />}
|
||||||
label="Dringend"
|
label="Überfällig"
|
||||||
value={insights.urgentCount}
|
value={ins.overdueCount}
|
||||||
color="#dc2626"
|
color="#dc2626"
|
||||||
|
active={filterHorizon === 'OVERDUE'}
|
||||||
|
onClick={() => toggleHorizon('OVERDUE')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<Calendar size={18} color="#ea580c" />}
|
icon={<Calendar size={18} color="#ea580c" />}
|
||||||
label="Diese Woche"
|
label="Diese Woche"
|
||||||
value={insights.dueThisWeek}
|
value={ins.dueThisWeek}
|
||||||
color="#ea580c"
|
color="#ea580c"
|
||||||
|
active={filterHorizon === 'THIS_WEEK'}
|
||||||
|
onClick={() => toggleHorizon('THIS_WEEK')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<CalendarDays size={18} color="#0369a1" />}
|
icon={<CalendarDays size={18} color="#0369a1" />}
|
||||||
label="Dieser Monat"
|
label="Dieser Monat"
|
||||||
value={insights.dueThisMonth}
|
value={ins.dueThisMonth}
|
||||||
color="#0369a1"
|
color="#0369a1"
|
||||||
|
active={filterHorizon === 'THIS_MONTH'}
|
||||||
|
onClick={() => toggleHorizon('THIS_MONTH')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<Eye size={18} color="#be185d" />}
|
icon={<Eye size={18} color="#be185d" />}
|
||||||
label="Pre-Market Risiko"
|
label="Pre-Market Risiko"
|
||||||
value={insights.schattenmarktReadyCount}
|
value={ins.schattenmarktReadyCount}
|
||||||
color="#be185d"
|
color="#be185d"
|
||||||
|
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
|
||||||
|
onClick={togglePreMarket}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
|
|||||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||||
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { ReminderStatus } from '../../domain/reminder'
|
import { ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
||||||
|
|
||||||
|
const PRIORITY_BORDER: Record<string, string> = {
|
||||||
|
[ReminderPriority.URGENT]: '#dc2626',
|
||||||
|
[ReminderPriority.HIGH]: '#ea580c',
|
||||||
|
[ReminderPriority.MEDIUM]: '#ca8a04',
|
||||||
|
[ReminderPriority.LOW]: 'transparent',
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
||||||
ACTIVE: 'info',
|
ACTIVE: 'info',
|
||||||
@@ -56,6 +63,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
|||||||
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
@@ -64,9 +73,11 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
|||||||
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2,
|
pl: 1.5,
|
||||||
|
pr: 2,
|
||||||
py: 1.25,
|
py: 1.25,
|
||||||
borderBottom: '1px solid #f1f5f9',
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
borderLeft: `3px solid ${borderColor}`,
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': { bgcolor: '#f8fafc' },
|
'&:hover': { bgcolor: '#f8fafc' },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function getAssetTypeLabel(type: AssetType): string {
|
|||||||
GASTRO: 'Gastro',
|
GASTRO: 'Gastro',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
UNKNOWN: 'Unbekannt',
|
UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
return labels[type] ?? type
|
return labels[type] ?? type
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export type AssetType = typeof AssetType[keyof typeof AssetType]
|
|||||||
// ── Result Types ──────────────────────────────────────────────────────────────
|
// ── Result Types ──────────────────────────────────────────────────────────────
|
||||||
export const ResultType = {
|
export const ResultType = {
|
||||||
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
|
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
|
||||||
EXTERNAL_MARKET: 'EXTERNAL_MARKET',
|
|
||||||
MAISON_WORK: 'MAISON_WORK',
|
MAISON_WORK: 'MAISON_WORK',
|
||||||
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
|
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
|
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
|
||||||
|
|
||||||
|
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
|
||||||
|
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
|
||||||
|
|
||||||
|
export interface ExtractedContact {
|
||||||
|
type: ContactType
|
||||||
|
value: string
|
||||||
|
confidence: ContactConfidence
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignalSource {
|
export interface SignalSource {
|
||||||
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
|
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
|
||||||
url?: string
|
url?: string
|
||||||
@@ -58,4 +68,7 @@ export interface FutureSignal {
|
|||||||
|
|
||||||
// Unit-level reference: if set, this signal is about a specific rentable unit
|
// Unit-level reference: if set, this signal is about a specific rentable unit
|
||||||
unitId?: string
|
unitId?: string
|
||||||
|
|
||||||
|
// KI-extracted contact information from web crawler sources
|
||||||
|
extractedContacts?: ExtractedContact[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export interface Inquiry {
|
|||||||
tenantName: string
|
tenantName: string
|
||||||
tenantCompany?: string
|
tenantCompany?: string
|
||||||
tenantEmail?: string
|
tenantEmail?: string
|
||||||
|
propertyManagerName?: string
|
||||||
|
propertyManagerCompany?: string
|
||||||
|
propertyAddress?: string
|
||||||
subject: string
|
subject: string
|
||||||
message: string
|
message: string
|
||||||
status?: InquiryStatus
|
status?: InquiryStatus
|
||||||
|
|||||||
@@ -11,8 +11,32 @@ export interface PropertyMarketSignal {
|
|||||||
confidence?: number
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key market performance indicators for a given radius.
|
||||||
|
// Base values are stored at 5 km; other radii are computed via getKpisForRadius().
|
||||||
|
export interface MarketKpis {
|
||||||
|
vacancyRatePct: number // % of comparable properties currently vacant
|
||||||
|
activePlatformSearches: number // active search requests on the platform
|
||||||
|
avgVacancyMonths: number // average months comparable properties remain vacant
|
||||||
|
demandSupplyRatio: number // demand / supply ratio (> 1 = more demand than supply)
|
||||||
|
comparableListings: number // number of comparable active listings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scale base KPIs (at 5 km) for a larger radius. */
|
||||||
|
export function getKpisForRadius(base: MarketKpis, radiusKm: number): MarketKpis {
|
||||||
|
const f = radiusKm / 5
|
||||||
|
return {
|
||||||
|
vacancyRatePct: +Math.min(35, base.vacancyRatePct + (f - 1) * 1.8).toFixed(1),
|
||||||
|
activePlatformSearches: Math.round(base.activePlatformSearches * f * 0.85),
|
||||||
|
avgVacancyMonths: +Math.min(24, base.avgVacancyMonths * (1 + (f - 1) * 0.12)).toFixed(1),
|
||||||
|
demandSupplyRatio: +Math.max(0.3, base.demandSupplyRatio / (1 + (f - 1) * 0.18)).toFixed(1),
|
||||||
|
comparableListings: Math.round(base.comparableListings * f * 1.1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarketReport {
|
export interface MarketReport {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
|
assetTypeLabel: string // e.g. "Büro"
|
||||||
|
kpis: MarketKpis // base values at 5 km radius
|
||||||
signals: PropertyMarketSignal[]
|
signals: PropertyMarketSignal[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AssetType } from './enums'
|
||||||
|
|
||||||
|
export type HinweisVisibility = 'INTERN' | 'PLATTFORM'
|
||||||
|
export type HinweisDirection = 'SUCHE' | 'VERFUEGBARKEIT'
|
||||||
|
export type HinweisStatus = 'OFFEN' | 'IN_BEARBEITUNG' | 'ARCHIVIERT'
|
||||||
|
export type HinweisQuelle = 'NETZWERKEVENT' | 'TELEFONAT' | 'BESICHTIGUNG' | 'MESSE' | 'EMAIL' | 'SONSTIGES'
|
||||||
|
|
||||||
|
export interface MarktHinweis {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
createdBy: string
|
||||||
|
verwaltungId: string
|
||||||
|
verwaltungName: string
|
||||||
|
direction: HinweisDirection
|
||||||
|
assetType: AssetType
|
||||||
|
locationHint: string
|
||||||
|
areaSqmMin?: number
|
||||||
|
areaSqmMax?: number
|
||||||
|
companyName?: string
|
||||||
|
isAnonymized: boolean
|
||||||
|
quelle: HinweisQuelle
|
||||||
|
note?: string
|
||||||
|
visibility: HinweisVisibility
|
||||||
|
status: HinweisStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateMarktHinweisInput = Omit<MarktHinweis, 'id' | 'createdAt' | 'createdBy' | 'verwaltungId' | 'verwaltungName'>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
export type PipelineStage = 'SAVED' | 'DISCOVERED' | 'QUALIFIED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
|
export type PipelineStage = 'SAVED' | 'CONTACTED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
|
||||||
|
|
||||||
export interface PipelineItem {
|
export interface PipelineItem {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
location: string
|
location: string
|
||||||
matchScore: number
|
matchScore: number
|
||||||
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
||||||
stage: PipelineStage
|
stage: PipelineStage
|
||||||
// Source match reference — used for navigating back to the detail page
|
// Source match reference — used for navigating back to the detail page
|
||||||
matchId?: string
|
matchId?: string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
||||||
} from './enums'
|
} from './enums'
|
||||||
import { AvailabilityStatus as AS } from './enums'
|
import { AvailabilityStatus as AS } from './enums'
|
||||||
|
import type { PropertyUnit } from './unit'
|
||||||
|
|
||||||
// Re-export unit types so all existing imports from 'property' continue to work
|
// Re-export unit types so all existing imports from 'property' continue to work
|
||||||
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
||||||
@@ -134,6 +135,9 @@ export interface Property {
|
|||||||
riskLevel?: RiskLevel
|
riskLevel?: RiskLevel
|
||||||
description?: string
|
description?: string
|
||||||
images?: string[]
|
images?: string[]
|
||||||
|
floorPlanUrl?: string
|
||||||
|
leaseContractUrl?: string // link to the signed lease document
|
||||||
|
leaseContractName?: string // display label, e.g. "Mietvertrag 2021–2026"
|
||||||
|
|
||||||
propertyNumber?: string
|
propertyNumber?: string
|
||||||
units?: PropertyUnit[]
|
units?: PropertyUnit[]
|
||||||
@@ -149,7 +153,7 @@ export interface Property {
|
|||||||
importedAt?: string
|
importedAt?: string
|
||||||
lastUpdatedAt?: string
|
lastUpdatedAt?: string
|
||||||
|
|
||||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number }
|
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
|
||||||
|
|
||||||
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
||||||
lastReviewedAt?: string
|
lastReviewedAt?: string
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const DATA_QUALITY_MODIFIER = {
|
|||||||
export const CONFIDENCE_MODIFIER = {
|
export const CONFIDENCE_MODIFIER = {
|
||||||
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
|
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
|
||||||
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
|
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
|
||||||
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
|
|
||||||
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
|
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
|
||||||
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
|
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
|
||||||
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
|
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
|
||||||
|
|||||||
@@ -15,14 +15,7 @@ interface UnifiedResultBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
||||||
resultType: 'VERIFIED_PORTFOLIO'
|
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK'
|
||||||
property: Property
|
|
||||||
match: Match
|
|
||||||
unit?: PropertyUnit // specific unit this match refers to
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalMarketResult extends UnifiedResultBase {
|
|
||||||
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
|
|
||||||
property: Property
|
property: Property
|
||||||
match: Match
|
match: Match
|
||||||
unit?: PropertyUnit // specific unit this match refers to
|
unit?: PropertyUnit // specific unit this match refers to
|
||||||
@@ -36,7 +29,9 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
|||||||
unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
|
unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Alias kept for backward compatibility — use VerifiedPortfolioResult */
|
||||||
|
export type ExternalMarketResult = VerifiedPortfolioResult
|
||||||
|
|
||||||
export type UnifiedMatchResult =
|
export type UnifiedMatchResult =
|
||||||
| VerifiedPortfolioResult
|
| VerifiedPortfolioResult
|
||||||
| ExternalMarketResult
|
|
||||||
| FutureAvailabilityResult
|
| FutureAvailabilityResult
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PropertyUnit {
|
|||||||
currentTenant?: string
|
currentTenant?: string
|
||||||
leaseTerm?: string
|
leaseTerm?: string
|
||||||
leaseEndDate?: string
|
leaseEndDate?: string
|
||||||
|
floorPlanUrl?: string // optional floor plan image
|
||||||
// Flexible letting (Teilfläche)
|
// Flexible letting (Teilfläche)
|
||||||
isFlexible?: boolean
|
isFlexible?: boolean
|
||||||
minLettableSqm?: number
|
minLettableSqm?: number
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
|||||||
|
|
||||||
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
||||||
const vm = buildMatchCardViewModel(result, [])
|
const vm = buildMatchCardViewModel(result, [])
|
||||||
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
expect(vm.scoreBreakdown!.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||||
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
expect(vm.scoreBreakdown!.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||||
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore)
|
expect(vm.scoreBreakdown!.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does NOT set a disclaimer for verified portfolio results', () => {
|
it('does NOT set a disclaimer for verified portfolio results', () => {
|
||||||
@@ -65,7 +65,7 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('passes through supplied actions unchanged', () => {
|
it('passes through supplied actions unchanged', () => {
|
||||||
const actions = [{ label: 'Shortlist', actionType: 'SHORTLIST' as const, primary: true }]
|
const actions = [{ id: 'shortlist', label: 'Shortlist', actionType: 'SAVE_SHORTLIST' as const, primary: true, onClick: () => {} }]
|
||||||
const vm = buildMatchCardViewModel(result, actions)
|
const vm = buildMatchCardViewModel(result, actions)
|
||||||
expect(vm.actions).toBe(actions)
|
expect(vm.actions).toBe(actions)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,20 +38,17 @@ describe('rankMatches', () => {
|
|||||||
expect(ranked[0].propertyId).toBe('high')
|
expect(ranked[0].propertyId).toBe('high')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('breaks ties by result type: VERIFIED_PORTFOLIO before EXTERNAL_MARKET', () => {
|
it('breaks ties by result type: VERIFIED_PORTFOLIO and MAISON_WORK ranked above FUTURE_AVAILABILITY', () => {
|
||||||
// Build one of each type but force identical score by using same property body
|
// Build one of each type but force identical score by using same property body
|
||||||
const prop = makeProperty()
|
const prop = makeProperty()
|
||||||
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
|
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
const external = buildFullMatch(need, { ...prop, id: 'e', resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.85 })
|
const future = buildFullMatch(need, { ...prop, id: 'f', resultType: ResultType.FUTURE_AVAILABILITY, confidenceScore: 0.85 })
|
||||||
|
|
||||||
// If scores differ, force them equal for a clean tiebreak test
|
// Force same score for a clean tiebreak test
|
||||||
if (verified.matchScore !== external.matchScore) {
|
verified.matchScore = 75
|
||||||
const lower = Math.min(verified.matchScore, external.matchScore)
|
future.matchScore = 75
|
||||||
verified.matchScore = lower
|
|
||||||
external.matchScore = lower
|
|
||||||
}
|
|
||||||
|
|
||||||
const ranked = rankMatches([external, verified])
|
const ranked = rankMatches([future, verified])
|
||||||
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ describe('calcConfidenceModifier', () => {
|
|||||||
expect(calcConfidenceModifier(p)).toBe(0)
|
expect(calcConfidenceModifier(p)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns -3 for external market results', () => {
|
|
||||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.80 })
|
|
||||||
expect(calcConfidenceModifier(p)).toBe(-3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns -2 for Maison Work results', () => {
|
it('returns -2 for Maison Work results', () => {
|
||||||
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
||||||
expect(calcConfidenceModifier(p)).toBe(-2)
|
expect(calcConfidenceModifier(p)).toBe(-2)
|
||||||
@@ -72,9 +67,9 @@ describe('calcConfidenceModifier', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
||||||
// EXTERNAL_MARKET (-3) + LOW_CONFIDENCE (-10) = -13
|
// MAISON_WORK (-2) + LOW_CONFIDENCE (-10) = -12
|
||||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.45 })
|
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.45 })
|
||||||
expect(calcConfidenceModifier(p)).toBe(-13)
|
expect(calcConfidenceModifier(p)).toBe(-12)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
||||||
@@ -157,12 +152,12 @@ describe('calculateScore', () => {
|
|||||||
expect(output.finalScore).toBeGreaterThanOrEqual(85)
|
expect(output.finalScore).toBeGreaterThanOrEqual(85)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('scores a weak match (wrong city, over budget, poor DQ, external market) below 50', () => {
|
it('scores a weak match (wrong city, over budget, poor DQ, low confidence) below 50', () => {
|
||||||
const need = makeNeed({ preferredLocations: ['Zürich'] })
|
const need = makeNeed({ preferredLocations: ['Zürich'] })
|
||||||
const prop = makeProperty({
|
const prop = makeProperty({
|
||||||
location: { city: 'Basel', country: 'CH' },
|
location: { city: 'Basel', country: 'CH' },
|
||||||
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
|
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
|
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
|
||||||
dataQuality: {
|
dataQuality: {
|
||||||
score: 0.30, // CRITICAL → -15
|
score: 0.30, // CRITICAL → -15
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ describe('softFactorEnrichmentService — score range invariant (all keys, 0–1
|
|||||||
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
||||||
it('returns identical estimate for same location regardless of resultType', () => {
|
it('returns identical estimate for same location regardless of resultType', () => {
|
||||||
// The enrichment service is location-only — resultType is irrelevant.
|
// The enrichment service is location-only — resultType is irrelevant.
|
||||||
// FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score.
|
// FUTURE_AVAILABILITY (pre-market) and VERIFIED_PORTFOLIO should produce the same soft factor score.
|
||||||
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
|
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
|
||||||
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
|
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
|
||||||
|
|
||||||
@@ -141,16 +141,16 @@ describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
|||||||
address: sharedAddress,
|
address: sharedAddress,
|
||||||
resultType: ResultType.FUTURE_AVAILABILITY,
|
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||||
})
|
})
|
||||||
const marketResult = makeProperty({
|
const portfolioResult = makeProperty({
|
||||||
location: sharedLocation,
|
location: sharedLocation,
|
||||||
address: sharedAddress,
|
address: sharedAddress,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
})
|
})
|
||||||
|
|
||||||
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
||||||
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult)
|
const portfolioEst = softFactorEnrichmentService.estimate('accessibility', portfolioResult)
|
||||||
expect(futureEst?.score).toBe(marketEst?.score)
|
expect(futureEst?.score).toBe(portfolioEst?.score)
|
||||||
expect(futureEst?.label).toBe(marketEst?.label)
|
expect(futureEst?.label).toBe(portfolioEst?.label)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
|
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
|
||||||
|
|||||||
@@ -191,10 +191,10 @@ export function rankMatches(matches: Match[]): Match[] {
|
|||||||
return [...matches].sort((a, b) => {
|
return [...matches].sort((a, b) => {
|
||||||
// Primary: matchScore descending
|
// Primary: matchScore descending
|
||||||
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
|
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
|
||||||
// Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE
|
// Secondary: VERIFIED / MAISON_WORK > FUTURE
|
||||||
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 }
|
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, MAISON_WORK: 0, FUTURE_AVAILABILITY: 2 }
|
||||||
const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
const aOrder = typeOrder[a.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||||
const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
const bOrder = typeOrder[b.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||||
if (aOrder !== bOrder) return aOrder - bOrder
|
if (aOrder !== bOrder) return aOrder - bOrder
|
||||||
// Tertiary: higher confidence first
|
// Tertiary: higher confidence first
|
||||||
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
|
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
|
||||||
|
|||||||
@@ -499,8 +499,6 @@ export function calcConfidenceModifier(property: Property): number {
|
|||||||
let mod = 0
|
let mod = 0
|
||||||
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
||||||
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
|
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
|
||||||
} else if (property.resultType === ResultType.EXTERNAL_MARKET) {
|
|
||||||
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
|
|
||||||
} else if (property.resultType === ResultType.MAISON_WORK) {
|
} else if (property.resultType === ResultType.MAISON_WORK) {
|
||||||
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
||||||
} else {
|
} else {
|
||||||
@@ -586,17 +584,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
]
|
]
|
||||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||||
|
|
||||||
// ── Modifiers: data quality and confidence adjust the final score ─────────
|
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
|
||||||
// These are intentionally applied after the hard/soft weighted sum so that
|
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
|
||||||
// a low-confidence or poor-data property can be penalised without distorting
|
|
||||||
// the individual criterion breakdown.
|
|
||||||
const dataQualityModifier = calcDataQualityModifier(property)
|
|
||||||
const confidenceModifier = calcConfidenceModifier(property)
|
|
||||||
|
|
||||||
// ── Final score: hard/soft weighted sum + must-have penalty + modifiers ───
|
|
||||||
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
|
|
||||||
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
|
|
||||||
+ dataQualityModifier + confidenceModifier
|
|
||||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||||
|
|
||||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||||
@@ -624,8 +613,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
finalScore,
|
finalScore,
|
||||||
hardMatchScore,
|
hardMatchScore,
|
||||||
softFactorScore,
|
softFactorScore,
|
||||||
dataQualityModifier,
|
dataQualityModifier: 0,
|
||||||
confidenceModifier,
|
confidenceModifier: 0,
|
||||||
positiveFactors,
|
positiveFactors,
|
||||||
negativeFactors,
|
negativeFactors,
|
||||||
allHardFactors: hardFactors,
|
allHardFactors: hardFactors,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ describe('useCreateNeed', () => {
|
|||||||
preferredLocations: ['Zürich'],
|
preferredLocations: ['Zürich'],
|
||||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
|
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||||
|
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||||
|
confidenceInCriteria: 0.8,
|
||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
||||||
|
|||||||
@@ -71,9 +71,14 @@ describe('useCreateProperty', () => {
|
|||||||
assetType: 'LOGISTICS',
|
assetType: 'LOGISTICS',
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
location: { city: 'Bern', country: 'CH' },
|
location: { city: 'Bern', country: 'CH' },
|
||||||
|
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '3000', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 300,
|
areaSqm: 300,
|
||||||
rentPricePerSqm: 100,
|
rentPricePerSqm: 100,
|
||||||
|
availabilityDate: '2025-01-01',
|
||||||
availabilityStatus: 'AVAILABLE_NOW',
|
availabilityStatus: 'AVAILABLE_NOW',
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.8,
|
||||||
|
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { marktHinweisService } from '../services/marktHinweisService'
|
||||||
|
import type { CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||||
|
|
||||||
|
export function useMarktHinweise() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['marktHinweise'],
|
||||||
|
queryFn: () => marktHinweisService.getAll(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMarktHinweis() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateMarktHinweisInput) => marktHinweisService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['marktHinweise'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useMatchDetail } from './useMatches'
|
|||||||
import { propertyService } from '../services/propertyService'
|
import { propertyService } from '../services/propertyService'
|
||||||
import { needService } from '../services/needService'
|
import { needService } from '../services/needService'
|
||||||
import { futureSignalService } from '../services/futureSignalService'
|
import { futureSignalService } from '../services/futureSignalService'
|
||||||
|
import type { ItemResponse } from '../services/types'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
import type { Need } from '../domain/need'
|
import type { Need } from '../domain/need'
|
||||||
import type { FutureSignal } from '../domain/futureSignal'
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
@@ -13,14 +14,14 @@ export function useMatchDetailData(matchId: string) {
|
|||||||
const { data: match, isLoading } = useMatchDetail(matchId)
|
const { data: match, isLoading } = useMatchDetail(matchId)
|
||||||
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
|
||||||
const { data: property = null } = useQuery<Property | null>({
|
const { data: property = null } = useQuery<ItemResponse<Property | null>, Error, Property | null>({
|
||||||
queryKey: ['property', match?.propertyId],
|
queryKey: ['property', match?.propertyId],
|
||||||
queryFn: () => propertyService.getById(match!.propertyId),
|
queryFn: () => propertyService.getById(match!.propertyId),
|
||||||
enabled: !!match && !isFuture,
|
enabled: !!match && !isFuture,
|
||||||
select: r => r.data ?? null,
|
select: r => r.data ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: need = null } = useQuery<Need | null>({
|
const { data: need = null } = useQuery<ItemResponse<Need | null>, Error, Need | null>({
|
||||||
queryKey: ['need', match?.needId],
|
queryKey: ['need', match?.needId],
|
||||||
queryFn: () => needService.getById(match!.needId),
|
queryFn: () => needService.getById(match!.needId),
|
||||||
enabled: !!match?.needId,
|
enabled: !!match?.needId,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface NewListingFormState {
|
|||||||
// Images
|
// Images
|
||||||
images: string[]
|
images: string[]
|
||||||
imageInput: string
|
imageInput: string
|
||||||
|
floorPlanUrl: string
|
||||||
// AI
|
// AI
|
||||||
aiText: string
|
aiText: string
|
||||||
aiApplied: boolean
|
aiApplied: boolean
|
||||||
@@ -82,6 +83,7 @@ export interface NewListingFormHandlers {
|
|||||||
setContactEmail: (v: string) => void
|
setContactEmail: (v: string) => void
|
||||||
setContactPhone: (v: string) => void
|
setContactPhone: (v: string) => void
|
||||||
setImageInput: (v: string) => void
|
setImageInput: (v: string) => void
|
||||||
|
setFloorPlanUrl: (v: string) => void
|
||||||
setAiText: (v: string) => void
|
setAiText: (v: string) => void
|
||||||
addImage: () => void
|
addImage: () => void
|
||||||
removeImage: (index: number) => void
|
removeImage: (index: number) => void
|
||||||
@@ -113,6 +115,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
const [ceilingHeight, setCeilingHeight]= useState('')
|
const [ceilingHeight, setCeilingHeight]= useState('')
|
||||||
const [images, setImages] = useState<string[]>([])
|
const [images, setImages] = useState<string[]>([])
|
||||||
const [imageInput, setImageInput] = useState('')
|
const [imageInput, setImageInput] = useState('')
|
||||||
|
const [floorPlanUrl, setFloorPlanUrl] = useState('')
|
||||||
const [aiText, setAiText] = useState('')
|
const [aiText, setAiText] = useState('')
|
||||||
const [aiApplied, setAiApplied] = useState(false)
|
const [aiApplied, setAiApplied] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -157,7 +160,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
assetType, street, houseNumber, postalCode, city,
|
assetType, street, houseNumber, postalCode, city,
|
||||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||||
availableFrom, description, softLevels,
|
availableFrom, description, softLevels,
|
||||||
floor, fitOut, parking, ceilingHeight, images,
|
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
onSuccess: () => setCreated(true),
|
onSuccess: () => setCreated(true),
|
||||||
@@ -173,7 +176,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||||
setSoftLevels(emptySoftLevels())
|
setSoftLevels(emptySoftLevels())
|
||||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||||
setImages([]); setImageInput('')
|
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||||
setAiText(''); setAiApplied(false)
|
setAiText(''); setAiApplied(false)
|
||||||
setCreated(false); setError(null)
|
setCreated(false); setError(null)
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
street, houseNumber, postalCode, city,
|
street, houseNumber, postalCode, city,
|
||||||
softLevels, floor, fitOut, parking, ceilingHeight,
|
softLevels, floor, fitOut, parking, ceilingHeight,
|
||||||
contactName, contactEmail, contactPhone,
|
contactName, contactEmail, contactPhone,
|
||||||
images, imageInput, aiText, aiApplied,
|
images, imageInput, floorPlanUrl, aiText, aiApplied,
|
||||||
error, created,
|
error, created,
|
||||||
aiParsing: parseListingMutation.isPending,
|
aiParsing: parseListingMutation.isPending,
|
||||||
submitting: createProperty.isPending,
|
submitting: createProperty.isPending,
|
||||||
@@ -193,7 +196,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setSoftLevel,
|
setSoftLevel,
|
||||||
setFloor, setFitOut, setParking, setCeilingHeight,
|
setFloor, setFitOut, setParking, setCeilingHeight,
|
||||||
setContactName, setContactEmail, setContactPhone,
|
setContactName, setContactEmail, setContactPhone,
|
||||||
setImageInput, setAiText,
|
setImageInput, setFloorPlanUrl, setAiText,
|
||||||
addImage, removeImage,
|
addImage, removeImage,
|
||||||
handleAiParse, handleSubmit, resetForm,
|
handleAiParse, handleSubmit, resetForm,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface PropertyFilter {
|
|||||||
minAreaSqm?: number
|
minAreaSqm?: number
|
||||||
maxRentPerSqm?: number
|
maxRentPerSqm?: number
|
||||||
organizationId?: string
|
organizationId?: string
|
||||||
|
sourceType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProperties(filter?: PropertyFilter) {
|
export function useProperties(filter?: PropertyFilter) {
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ export function useSchattenmarktSignals(properties: Property[]): FutureSignal[]
|
|||||||
for (const unit of releasedUnits) {
|
for (const unit of releasedUnits) {
|
||||||
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
|
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
|
||||||
if (!availableFrom) continue
|
if (!availableFrom) continue
|
||||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths)
|
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths ?? 0)
|
||||||
if (MOCK_TODAY < triggerDate) continue
|
if (MOCK_TODAY < triggerDate) continue
|
||||||
signals.push(buildUnitSignal(p, unit, availableFrom))
|
signals.push(buildUnitSignal(p, unit, availableFrom))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Backward compat: property-level signal (no explicit unit releases defined)
|
// Backward compat: property-level signal (no explicit unit releases defined)
|
||||||
const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths)
|
const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths ?? 0)
|
||||||
if (!triggerDate || MOCK_TODAY < triggerDate) continue
|
if (!triggerDate || MOCK_TODAY < triggerDate) continue
|
||||||
signals.push(buildPropertySignal(p))
|
signals.push(buildPropertySignal(p))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useMatches, useMatchesByNeed } from './useMatches'
|
import { useMatchesByNeed } from './useMatches'
|
||||||
import { useProperties } from './useProperties'
|
import { useProperties } from './useProperties'
|
||||||
import { useFutureSignals } from './useFutureSignals'
|
import { useFutureSignals } from './useFutureSignals'
|
||||||
import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
||||||
import type {
|
import type {
|
||||||
UnifiedMatchResult,
|
UnifiedMatchResult,
|
||||||
VerifiedPortfolioResult,
|
VerifiedPortfolioResult,
|
||||||
ExternalMarketResult,
|
|
||||||
FutureAvailabilityResult,
|
|
||||||
} from '../domain/unifiedResult'
|
} from '../domain/unifiedResult'
|
||||||
|
|
||||||
export function useUnifiedResults(needId?: string) {
|
export function useUnifiedResults(needId?: string) {
|
||||||
const allMatchesQuery = useMatches()
|
|
||||||
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
const needMatchesQuery = useMatchesByNeed(needId ?? '')
|
||||||
const propertiesQuery = useProperties()
|
const propertiesQuery = useProperties()
|
||||||
const signalsQuery = useFutureSignals()
|
const signalsQuery = useFutureSignals()
|
||||||
|
|
||||||
const matchesQuery = needId ? needMatchesQuery : allMatchesQuery
|
// Always scope to a specific need — showing cross-need results causes duplicates per property
|
||||||
|
const matchesQuery = needMatchesQuery
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
|
||||||
@@ -64,24 +62,11 @@ export function useUnifiedResults(needId?: string) {
|
|||||||
? (property.units?.find(u => u.id === match.unitId) ?? undefined)
|
? (property.units?.find(u => u.id === match.unitId) ?? undefined)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') {
|
|
||||||
const result: ExternalMarketResult = {
|
|
||||||
matchId: match.id,
|
|
||||||
needId: match.needId,
|
|
||||||
matchScore: match.matchScore,
|
|
||||||
resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK',
|
|
||||||
property,
|
|
||||||
match,
|
|
||||||
unit: matchUnit,
|
|
||||||
}
|
|
||||||
return [result]
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: VerifiedPortfolioResult = {
|
const result: VerifiedPortfolioResult = {
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
needId: match.needId,
|
needId: match.needId,
|
||||||
matchScore: match.matchScore,
|
matchScore: match.matchScore,
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: rt as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||||
property,
|
property,
|
||||||
match,
|
match,
|
||||||
unit: matchUnit,
|
unit: matchUnit,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
|||||||
RETAIL: 'Retail',
|
RETAIL: 'Retail',
|
||||||
GASTRO: 'Gastronomie',
|
GASTRO: 'Gastronomie',
|
||||||
LOGISTICS: 'Logistik',
|
LOGISTICS: 'Logistik',
|
||||||
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,6 @@ export const ASSET_TYPE_LABELS: Record<string, string> = {
|
|||||||
// Result type display labels
|
// Result type display labels
|
||||||
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: 'Plattform',
|
VERIFIED_PORTFOLIO: 'Plattform',
|
||||||
EXTERNAL_MARKET: 'Plattform',
|
|
||||||
MAISON_WORK: 'Maison Work',
|
MAISON_WORK: 'Maison Work',
|
||||||
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
|
|||||||
export const DS_COLORS = {
|
export const DS_COLORS = {
|
||||||
resultType: {
|
resultType: {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
|
|
||||||
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.10)', fg: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
|
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
|
||||||
},
|
},
|
||||||
@@ -199,7 +198,6 @@ export function scoreToDataQualityLevel(score: number): DataQualityLevel {
|
|||||||
|
|
||||||
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
|
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
||||||
EXTERNAL_MARKET: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Maps a Need id (need-XXX) to its corresponding LatentNeed id (lneed-XXX). */
|
||||||
|
export const NEED_TO_LATENT_NEED: Record<string, string> = {
|
||||||
|
'need-001': 'lneed-007',
|
||||||
|
'need-002': 'lneed-008',
|
||||||
|
'need-004': 'lneed-009',
|
||||||
|
'need-005': 'lneed-010',
|
||||||
|
'need-012': 'lneed-011',
|
||||||
|
'need-013': 'lneed-012',
|
||||||
|
'need-014': 'lneed-013',
|
||||||
|
'need-015': 'lneed-014',
|
||||||
|
}
|
||||||
@@ -67,6 +67,10 @@ const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
|
|||||||
UserRole.PROPERTY_MANAGER,
|
UserRole.PROPERTY_MANAGER,
|
||||||
UserRole.DEMAND_USER,
|
UserRole.DEMAND_USER,
|
||||||
],
|
],
|
||||||
|
[WorkspaceType.OPERATIONS]: [
|
||||||
|
UserRole.SUPER_ADMIN,
|
||||||
|
UserRole.ORGANIZATION_ADMIN,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Core Functions ────────────────────────────────────────────────────────────
|
// ── Core Functions ────────────────────────────────────────────────────────────
|
||||||
@@ -99,7 +103,6 @@ export function canViewProperty(
|
|||||||
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
|
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
|
||||||
return property.organizationId === user.organizationId
|
return property.organizationId === user.organizationId
|
||||||
}
|
}
|
||||||
if (property.resultType === ResultType.EXTERNAL_MARKET) return true
|
|
||||||
if (property.resultType === ResultType.MAISON_WORK) return true
|
if (property.resultType === ResultType.MAISON_WORK) return true
|
||||||
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
||||||
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
|
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { AssetType } from '../domain/enums'
|
||||||
|
|
||||||
|
// ── Image pools (curated Unsplash IDs, all w=800&h=400&fit=crop) ──────────────
|
||||||
|
// Rule: ALL images are interior shots — no exteriors, no city skylines, no landscapes.
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const OFFICE_IDS = [
|
||||||
|
'photo-1497366858526-0766c4080517', // premium reception & lobby
|
||||||
|
'photo-1497366811353-6870744d04b2', // floor-to-ceiling glass, elegant interior
|
||||||
|
'photo-1497366216548-37526070297c', // clean open-plan office
|
||||||
|
'photo-1454165804606-c3d57bc86b40', // corporate meeting / workspace
|
||||||
|
'photo-1568992687947-868a62a9f521', // contemporary office with plants
|
||||||
|
'photo-1572025442646-866d16c84a54', // bright modern office
|
||||||
|
'photo-1553028826-f4804a6dba3b', // open workspace, natural light
|
||||||
|
'photo-1534536281715-e28d76689b4d', // converted warehouse loft
|
||||||
|
'photo-1556761175-5973dc0f32e7', // industrial co-working, exposed concrete
|
||||||
|
'photo-1541746972996-4e0b0f43e02a', // creative loft workspace
|
||||||
|
'photo-1515187029135-18ee286d815b', // brick + open ceiling office
|
||||||
|
'photo-1504384308090-c894fdcc538d', // industrial-chic open space
|
||||||
|
]
|
||||||
|
|
||||||
|
// Rotate so each sub-pool maps the same sequential index to a different photo
|
||||||
|
const rotate = <T>(arr: T[], n: number): T[] => [...arr.slice(n), ...arr.slice(0, n)]
|
||||||
|
|
||||||
|
const POOL = {
|
||||||
|
office_premium: rotate(OFFICE_IDS, 0),
|
||||||
|
office_modern: rotate(OFFICE_IDS, 4),
|
||||||
|
office_industrial: rotate(OFFICE_IDS, 8),
|
||||||
|
logistics: [
|
||||||
|
'photo-1586528116311-ad8dd3c8310d',
|
||||||
|
'photo-1553413077-190dd305871c',
|
||||||
|
'photo-1587293852726-70cfa4d5f99f',
|
||||||
|
'photo-1566932769119-25a49e6b5c6d',
|
||||||
|
'photo-1474396651759-b49538a26a2d',
|
||||||
|
],
|
||||||
|
retail: [
|
||||||
|
'photo-1441986300917-64674bd600d8',
|
||||||
|
'photo-1567958451986-2de427a4a0be',
|
||||||
|
'photo-1555529669-e69e7aa0ba9a',
|
||||||
|
'photo-1604719312566-8912e9227c6a',
|
||||||
|
'photo-1555396273-367ea4eb4db5',
|
||||||
|
'photo-1472851294608-062f824d29cc',
|
||||||
|
'photo-1483985988355-763728e1802b',
|
||||||
|
'photo-1445205170230-053b83016050',
|
||||||
|
],
|
||||||
|
gastro: [
|
||||||
|
'photo-1414235077428-338989a2e8c0',
|
||||||
|
'photo-1517248135467-4c7edcad34c4',
|
||||||
|
'photo-1544148103-0773bf10d330',
|
||||||
|
'photo-1559339352-11d035aa65ce',
|
||||||
|
'photo-1466978913421-dad2ebd01d17',
|
||||||
|
'photo-1521017432531-fbd92d768814',
|
||||||
|
],
|
||||||
|
light_industrial: [
|
||||||
|
'photo-1565515636339-5de8c80eba38',
|
||||||
|
'photo-1581091226825-a6a2a5aee158',
|
||||||
|
'photo-1571008887538-b36bb32f4571',
|
||||||
|
'photo-1504307651254-35680f356dfd',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = 'https://images.unsplash.com/'
|
||||||
|
const PARAMS = '?w=800&h=400&fit=crop'
|
||||||
|
|
||||||
|
// Sequential pick — guaranteed unique until pool wraps (used at init time in mock data)
|
||||||
|
function pickAt(pool: string[], index: number): string {
|
||||||
|
return `${BASE}${pool[index % pool.length]}${PARAMS}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── District sets (exported so properties.ts can replicate the pool-key logic) ──
|
||||||
|
|
||||||
|
export const PREMIUM_DISTRICTS = new Set([
|
||||||
|
'innenstadt', 'kreis 1', 'altstadt', 'seefeld', 'kreis 8',
|
||||||
|
'city', 'zürich city', 'bern innenstadt', 'zug innenstadt',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const INDUSTRIAL_DISTRICTS = new Set([
|
||||||
|
'zürich-west', 'zürich west', 'west', 'binz', 'zürich-binz',
|
||||||
|
'altstetten', 'hürlimann', 'technopark', 'industrial', 'industriezone',
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Pool-key helper (exported so properties.ts can count per pool) ─────────────
|
||||||
|
|
||||||
|
export type PoolKey = keyof typeof POOL
|
||||||
|
|
||||||
|
export function getPoolKey(assetType: string | undefined, district: string, prestige: number): PoolKey {
|
||||||
|
switch (assetType) {
|
||||||
|
case AssetType.LOGISTICS: return 'logistics'
|
||||||
|
case AssetType.RETAIL: return 'retail'
|
||||||
|
case AssetType.GASTRO: return 'gastro'
|
||||||
|
case AssetType.LIGHT_INDUSTRIAL:
|
||||||
|
case AssetType.PRODUCTION: return 'light_industrial'
|
||||||
|
default: {
|
||||||
|
if (prestige >= 78 || PREMIUM_DISTRICTS.has(district)) return 'office_premium'
|
||||||
|
if (INDUSTRIAL_DISTRICTS.has(district)) return 'office_industrial'
|
||||||
|
return 'office_modern'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main resolver ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ImageResolverParams {
|
||||||
|
assetType?: string
|
||||||
|
city?: string
|
||||||
|
district?: string
|
||||||
|
prestige?: number
|
||||||
|
floorLevel?: number
|
||||||
|
propertyId?: string
|
||||||
|
poolIndex?: number // sequential index within pool — set by properties.ts forEach for duplicate-free assignment
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePropertyImage(p: ImageResolverParams): string {
|
||||||
|
const district = (p.district ?? '').toLowerCase()
|
||||||
|
const prestige = p.prestige ?? 60
|
||||||
|
const key = getPoolKey(p.assetType, district, prestige)
|
||||||
|
const pool = POOL[key]
|
||||||
|
// Sequential index (set at mock-data init) guarantees no duplicates within a pool
|
||||||
|
if (p.poolIndex !== undefined) return pickAt(pool, p.poolIndex)
|
||||||
|
// Fallback: hash-based (used when poolIndex is unavailable, e.g. runtime calls)
|
||||||
|
let h = 0
|
||||||
|
const seed = p.propertyId ?? `${p.city}${p.district}${p.assetType}`
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0
|
||||||
|
return pickAt(pool, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image overlay label ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ASSET_LABELS: Partial<Record<string, string>> = {
|
||||||
|
[AssetType.OFFICE]: 'Bürofläche',
|
||||||
|
[AssetType.RETAIL]: 'Retail EG',
|
||||||
|
[AssetType.GASTRO]: 'Gastrofläche',
|
||||||
|
[AssetType.LIGHT_INDUSTRIAL]: 'Gewerbe',
|
||||||
|
[AssetType.LOGISTICS]: 'Logistik',
|
||||||
|
[AssetType.PRODUCTION]: 'Produktion',
|
||||||
|
[AssetType.MIXED]: 'Gewerbefläche',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveImageLabel(p: ImageResolverParams): { type: string; location: string } {
|
||||||
|
const type = ASSET_LABELS[p.assetType ?? ''] ?? 'Gewerbefläche'
|
||||||
|
const district = p.district ?? p.city ?? ''
|
||||||
|
const location = p.city && district !== p.city ? `${p.city} · ${district}` : (p.city ?? district)
|
||||||
|
return { type, location }
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { Inquiry } from '../domain/inquiry'
|
||||||
|
|
||||||
|
export const mockDemandInquiries: Inquiry[] = [
|
||||||
|
// 1 — NEW (unanswered, unread from manager)
|
||||||
|
{
|
||||||
|
id: 'dinq-001',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-technopark',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Martin Wegmann',
|
||||||
|
propertyManagerCompany: 'Technopark Zürich AG',
|
||||||
|
propertyAddress: 'Bürofläche Technoparkstrasse 1',
|
||||||
|
subject: 'Anfrage: Bürofläche Technoparkstrasse 1',
|
||||||
|
message:
|
||||||
|
'Guten Tag Herr Wegmann\n\nWir interessieren uns für die Bürofläche an der Technoparkstrasse 1 in Zürich. Können Sie uns bitte Unterlagen sowie einen Besichtigungstermin für die kommende Woche anbieten?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
status: 'new',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
matchScore: 94,
|
||||||
|
createdAt: '2026-05-22T09:15:00Z',
|
||||||
|
updatedAt: '2026-05-22T09:15:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-001-1',
|
||||||
|
inquiryId: 'dinq-001',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Guten Tag Herr Wegmann\n\nWir interessieren uns für die Bürofläche an der Technoparkstrasse 1 in Zürich. Können Sie uns bitte Unterlagen sowie einen Besichtigungstermin für die kommende Woche anbieten?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-22T09:15:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2 — NEW (unanswered, unread badge)
|
||||||
|
{
|
||||||
|
id: 'dinq-002',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-hardturmpark',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Claudia Stöckli',
|
||||||
|
propertyManagerCompany: 'PSP Swiss Property AG',
|
||||||
|
propertyAddress: 'Bürofläche Hardturmstrasse 161',
|
||||||
|
subject: 'Anfrage: Bürofläche Hardturmstrasse 161',
|
||||||
|
message:
|
||||||
|
"Sehr geehrte Frau Stöckli\n\nWir prüfen Büroflächen in Zürich-West und sind auf Ihre Liegenschaft an der Hardturmstrasse 161 aufmerksam geworden. Verfügen Sie über eine Fläche zwischen 400 und 600 m²? Bitte senden Sie uns das aktuelle Exposé.\n\nBeste Grüsse\nAdmin User",
|
||||||
|
status: 'new',
|
||||||
|
unreadCount: 1,
|
||||||
|
isRead: false,
|
||||||
|
matchScore: 88,
|
||||||
|
createdAt: '2026-05-23T14:30:00Z',
|
||||||
|
updatedAt: '2026-05-23T14:30:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-002-1',
|
||||||
|
inquiryId: 'dinq-002',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
"Sehr geehrte Frau Stöckli\n\nWir prüfen Büroflächen in Zürich-West und sind auf Ihre Liegenschaft an der Hardturmstrasse 161 aufmerksam geworden. Verfügen Sie über eine Fläche zwischen 400 und 600 m²? Bitte senden Sie uns das aktuelle Exposé.\n\nBeste Grüsse\nAdmin User",
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-23T14:30:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3 — IN_PROGRESS (ongoing conversation)
|
||||||
|
{
|
||||||
|
id: 'dinq-003',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-sihlcity',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Thomas Brunner',
|
||||||
|
propertyManagerCompany: 'Wincasa AG',
|
||||||
|
propertyAddress: 'Bürofläche Kalanderplatz 1, Sihlcity',
|
||||||
|
subject: 'Anfrage: Bürofläche Kalanderplatz 1, Sihlcity',
|
||||||
|
message:
|
||||||
|
'Guten Tag Herr Brunner\n\nDie Bürofläche im Sihlcity entspricht unseren Anforderungen sehr gut. Könnten Sie uns die Mietkonditionen sowie einen Grundriss zukommen lassen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
status: 'in_progress',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-05-21T17:00:00Z',
|
||||||
|
matchScore: 91,
|
||||||
|
createdAt: '2026-05-19T10:00:00Z',
|
||||||
|
updatedAt: '2026-05-21T16:45:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-003-1',
|
||||||
|
inquiryId: 'dinq-003',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Guten Tag Herr Brunner\n\nDie Bürofläche im Sihlcity entspricht unseren Anforderungen sehr gut. Könnten Sie uns die Mietkonditionen sowie einen Grundriss zukommen lassen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-19T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-003-2',
|
||||||
|
inquiryId: 'dinq-003',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Thomas Brunner',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nVielen Dank für Ihr Interesse. Anbei das Exposé mit Grundriss und Konditionsübersicht. Die Fläche steht ab 1. August zur Verfügung. Haben Sie Interesse an einer Besichtigung diese Woche?\n\nFreundliche Grüsse\nThomas Brunner\nWincasa AG',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'datt-003-1', fileName: 'Expose_Sihlcity_Kalanderplatz.pdf', fileType: 'application/pdf', fileSize: 1540000 },
|
||||||
|
],
|
||||||
|
createdAt: '2026-05-20T09:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-003-3',
|
||||||
|
inquiryId: 'dinq-003',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Herzlichen Dank für die schnelle Antwort. Ja, eine Besichtigung würde uns gut passen. Wie sieht es am Donnerstag um 10:00 Uhr aus?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-21T16:45:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4 — IN_PROGRESS (ongoing, verwalter replied)
|
||||||
|
{
|
||||||
|
id: 'dinq-004',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-bahnhofzug',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Sandra Huber',
|
||||||
|
propertyManagerCompany: 'Swiss Prime Site AG',
|
||||||
|
propertyAddress: 'Bürofläche Bahnhofstrasse 28, Zug',
|
||||||
|
subject: 'Anfrage: Bürofläche Bahnhofstrasse 28, Zug',
|
||||||
|
message:
|
||||||
|
'Sehr geehrte Frau Huber\n\nWir suchen repräsentative Büroflächen im Kanton Zug. Ihre Liegenschaft an der Bahnhofstrasse entspricht unserem Profil. Können Sie uns Details zu Verfügbarkeit und Nebenkosten mitteilen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
status: 'in_progress',
|
||||||
|
unreadCount: 2,
|
||||||
|
isRead: false,
|
||||||
|
matchScore: 86,
|
||||||
|
createdAt: '2026-05-16T08:45:00Z',
|
||||||
|
updatedAt: '2026-05-20T11:00:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-004-1',
|
||||||
|
inquiryId: 'dinq-004',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Sehr geehrte Frau Huber\n\nWir suchen repräsentative Büroflächen im Kanton Zug. Ihre Liegenschaft an der Bahnhofstrasse entspricht unserem Profil. Können Sie uns Details zu Verfügbarkeit und Nebenkosten mitteilen?\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-16T08:45:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-004-2',
|
||||||
|
inquiryId: 'dinq-004',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Sandra Huber',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nVielen Dank für Ihre Nachricht. Die Fläche von ca. 520 m² ist ab Oktober verfügbar. Der Nettomietzins beträgt CHF 290/m² p.a. Nebenkosten werden nach Aufwand abgerechnet. Darf ich Ihnen ein Besichtigungsfenster vorschlagen?\n\nFreundliche Grüsse\nSandra Huber\nSwiss Prime Site AG',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'datt-004-1', fileName: 'Konditionsblatt_Bahnhofstr28.pdf', fileType: 'application/pdf', fileSize: 820000 },
|
||||||
|
],
|
||||||
|
createdAt: '2026-05-18T14:20:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-004-3',
|
||||||
|
inquiryId: 'dinq-004',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Sandra Huber',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nNur eine kurze Nachfrage — haben Sie die Unterlagen erhalten? Gerne würden wir einen Termin festlegen.\n\nFreundliche Grüsse\nSandra Huber',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-20T11:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5 — ANSWERED (completed exchange)
|
||||||
|
{
|
||||||
|
id: 'dinq-005',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
propertyId: 'prop-dreispitz',
|
||||||
|
tenantName: 'Admin User',
|
||||||
|
tenantCompany: 'Mobimo Management AG',
|
||||||
|
tenantEmail: 'admin@ideal-sharing.ch',
|
||||||
|
propertyManagerName: 'Reto Mäder',
|
||||||
|
propertyManagerCompany: 'Allreal AG',
|
||||||
|
propertyAddress: 'Logistikfläche Dreispitz Basel',
|
||||||
|
subject: 'Anfrage: Logistikfläche Dreispitz Basel',
|
||||||
|
message:
|
||||||
|
"Guten Tag Herr Mäder\n\nWir suchen ab Herbst 2026 eine Logistikfläche im Raum Basel. Ihre Liegenschaft im Dreispitz würde unseren Anforderungen entsprechen. Ist die Fläche noch verfügbar?\n\nFreundliche Grüsse\nAdmin User",
|
||||||
|
status: 'answered',
|
||||||
|
unreadCount: 0,
|
||||||
|
isRead: true,
|
||||||
|
lastReadAt: '2026-05-14T10:00:00Z',
|
||||||
|
matchScore: 79,
|
||||||
|
createdAt: '2026-05-10T11:00:00Z',
|
||||||
|
updatedAt: '2026-05-14T09:45:00Z',
|
||||||
|
thread: [
|
||||||
|
{
|
||||||
|
id: 'dmsg-005-1',
|
||||||
|
inquiryId: 'dinq-005',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
"Guten Tag Herr Mäder\n\nWir suchen ab Herbst 2026 eine Logistikfläche im Raum Basel. Ihre Liegenschaft im Dreispitz würde unseren Anforderungen entsprechen. Ist die Fläche noch verfügbar?\n\nFreundliche Grüsse\nAdmin User",
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-10T11:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-005-2',
|
||||||
|
inquiryId: 'dinq-005',
|
||||||
|
senderType: 'supply_user',
|
||||||
|
senderName: 'Reto Mäder',
|
||||||
|
body:
|
||||||
|
'Guten Tag\n\nJa, die Fläche ist noch verfügbar. Wir haben ca. 1\'800 m² mit 5.5 m Hallenhöhe und zwei Rampen. Besichtigung möglich ab nächster Woche. Anbei das Datenblatt.\n\nFreundliche Grüsse\nReto Mäder\nAllreal AG',
|
||||||
|
attachments: [
|
||||||
|
{ id: 'datt-005-1', fileName: 'Datenblatt_Dreispitz_Logistik.pdf', fileType: 'application/pdf', fileSize: 1100000 },
|
||||||
|
],
|
||||||
|
createdAt: '2026-05-12T08:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dmsg-005-3',
|
||||||
|
inquiryId: 'dinq-005',
|
||||||
|
senderType: 'tenant',
|
||||||
|
senderName: 'Admin User',
|
||||||
|
body:
|
||||||
|
'Herzlichen Dank. Die Unterlagen sehen vielversprechend aus. Wir melden uns bis Ende Woche mit einem Besichtigungstermin.\n\nFreundliche Grüsse\nAdmin User',
|
||||||
|
attachments: [],
|
||||||
|
createdAt: '2026-05-14T09:45:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
+167
-119
@@ -422,48 +422,58 @@ export const mockFutureSignals: FutureSignal[] = [
|
|||||||
unconfirmedFacts: ['Welche Lagerstandorte konkret betroffen', 'Zeitpunkt eines möglichen Auszugs', 'Ob Fläche ganz oder teilweise aufgegeben'],
|
unconfirmedFacts: ['Welche Lagerstandorte konkret betroffen', 'Zeitpunkt eines möglichen Auszugs', 'Ob Fläche ganz oder teilweise aufgegeben'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── DEMAND signals — company is looking for space → Markt-Leads (Verwaltung) ─
|
// ── DEMAND signals — company is looking for space → KI-Signale (Verwaltung) ─
|
||||||
|
|
||||||
// signal-001: DataCloud Systems AG — Büroexpansion Zürich-West
|
// signal-001: Alpbach Advisors AG — Neuer Stadtstandort Zürich
|
||||||
|
// Signal type: Strategic announcement — firm opening Zürich branch for client proximity.
|
||||||
|
// NOT a hiring surge. Trigger is a press release + LinkedIn location updates of key staff.
|
||||||
{
|
{
|
||||||
id: 'signal-001',
|
id: 'signal-001',
|
||||||
signalType: SignalType.EXPANSION,
|
signalType: SignalType.EXPANSION,
|
||||||
signalDirection: 'DEMAND',
|
signalDirection: 'DEMAND',
|
||||||
companyName: 'DataCloud Systems AG',
|
companyName: 'Alpbach Advisors AG',
|
||||||
locationHint: 'Zürich-West / Technopark',
|
locationHint: 'Zürich, Innenstadt / Paradeplatz',
|
||||||
title: 'DataCloud Systems AG sucht ~600 m² Bürofläche in Zürich-West',
|
title: 'Alpbach Advisors AG eröffnet Zürich-Büro — Standort gesucht',
|
||||||
areaSqmEstimate: 600,
|
areaSqmEstimate: 320,
|
||||||
probability: 0.72,
|
probability: 0.78,
|
||||||
confidenceScore: 0.68,
|
confidenceScore: 0.74,
|
||||||
timeHorizonMonths: 10,
|
timeHorizonMonths: 8,
|
||||||
source: {
|
source: {
|
||||||
type: 'JOB_POSTING',
|
type: 'PRESS',
|
||||||
url: 'https://example.com/jobs/datacloud',
|
url: 'https://example.com/news/alpbach-zuerich-expansion',
|
||||||
publishedAt: '2025-04-20',
|
publishedAt: '2025-04-15',
|
||||||
credibility: 'MEDIUM',
|
credibility: 'HIGH',
|
||||||
},
|
},
|
||||||
sensitivityLevel: 'INTERNAL',
|
sensitivityLevel: 'INTERNAL',
|
||||||
disclaimer: 'Erkannte Flächennachfrage aus öffentlichen Quellen. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
disclaimer: 'Erkannte Flächennachfrage aus Medienmitteilung und LinkedIn-Daten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.LOW,
|
||||||
marketIndicator: 'Tech-Sektor Stellenwachstum +38% YoY',
|
marketIndicator: 'Wealth-Management Zürich: Neuansiedlungen +14% 2024',
|
||||||
relevanceScore: 0.76,
|
relevanceScore: 0.80,
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
expiresAt: '2026-03-01',
|
expiresAt: '2026-02-01',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
createdAt: '2025-05-01T07:00:00Z',
|
createdAt: '2025-04-18T08:00:00Z',
|
||||||
updatedAt: '2025-05-10T07:00:00Z',
|
updatedAt: '2025-05-12T09:00:00Z',
|
||||||
aiSummary: 'KI hat in 90 Tagen 14 Stelleninserate von DataCloud Systems AG mit explizitem Standort Zürich-West/Technopark ausgewertet. Das Wachstumsmuster (DevOps, Cloud-Architektur, Sales) deutet auf eine Teamvergrösserung von ~40 Personen hin — entspricht einem Flächenbedarf von ca. 600 m². Potenzieller Mieter für Büroflächen in Zürich-West.',
|
aiSummary: 'Alpbach Advisors AG (Luzern, 38 Mitarbeitende, Vermögensverwaltung) hat am 15. April eine Medienmitteilung veröffentlicht: «Alpbach eröffnet Zürich-Büro zur Stärkung der Kundennähe am Finanzplatz». Drei Senior-Partner haben ihren LinkedIn-Standort zeitgleich auf Zürich aktualisiert. Das ist ein strategischer Stadtstandort-Entscheid — kein Wachstum, sondern Nähe zu institutionellen Kunden am Paradeplatz. Typischer Flächenbedarf für ein Repräsentationsbüro dieser Grösse: 280–360 m², Lage Innenstadt oder Seefeld zwingend.',
|
||||||
marketIndicators: [
|
marketIndicators: [
|
||||||
'14 Stelleninserate mit Standort Zürich-West in 90 Tagen',
|
'Medienmitteilung vom 15. April: Zürich-Büro offiziell angekündigt',
|
||||||
'Teamwachstum ~40 Personen (DevOps, Cloud-Architektur, Sales)',
|
'3 Senior-Partner haben LinkedIn-Standort auf Zürich aktualisiert',
|
||||||
'Tech-Sektor Zürich-West: +38% Stellenwachstum 2024',
|
'Wealth-Management Zürich Innenstadt: Nachfrage nach Repräsentationsflächen stabil hoch',
|
||||||
|
],
|
||||||
|
strategicInterpretation: 'Öffentlich bestätigte Standorteröffnung — Flächensuche muss bereits aktiv sein (Ankündigung vor Einzug). Repräsentationsbüro verlangt Innenstadt/Paradeplatz-Nähe. Hohe Abschlusswahrscheinlichkeit da Entscheid bereits gefallen.',
|
||||||
|
confirmedFacts: ['Medienmitteilung öffentlich', 'Zürich-Standort strategisch bestätigt', 'Partner-Team bereits in Zürich aktiv'],
|
||||||
|
unconfirmedFacts: ['Exakte Lage (Paradeplatz vs. Seefeld)', 'Ausbaustandard (Repräsentation vs. Standard)', 'Einzugstermin'],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://alpbach-advisors.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Dr. Markus Suter (Managing Partner)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
|
||||||
|
{ type: 'EMAIL', value: 'zuerich@alpbach-advisors.ch', confidence: 'MEDIUM', label: 'Neue Zürich-Adresse (geschätzt)' },
|
||||||
|
{ type: 'LINKEDIN', value: 'linkedin.com/company/alpbach-advisors', confidence: 'HIGH', label: 'LinkedIn Firmenprofil' },
|
||||||
],
|
],
|
||||||
strategicInterpretation: 'DataCloud Systems AG befindet sich in einer klaren Wachstumsphase mit Fokus auf Zürich-West. Unternehmen mit diesem Wachstumsmuster sucht typischerweise 500–700 m² Bürofläche mit Ausbauoptionen.',
|
|
||||||
confirmedFacts: ['Region Zürich-West aus Inseraten bestätigt', 'Flächentyp Büro plausibel', 'Wachstumstrend seit 6 Monaten vorhanden'],
|
|
||||||
unconfirmedFacts: ['Exakter Flächenbedarf', 'Ob interner Ausbau am aktuellen Standort geplant', 'Zeitplan für die Entscheidung'],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// signal-004: Novabio Pharma AG — Laborexpansion Basel Allschwil
|
// signal-004: Novabio Pharma AG — Laborerweiterung Basel Allschwil
|
||||||
|
// Signal type: Business report — explicitly states capacity expansion in Life-Sciences corridor.
|
||||||
|
// Not hiring-driven. Trigger is published strategic plan + lab manager job profiles.
|
||||||
{
|
{
|
||||||
id: 'signal-004',
|
id: 'signal-004',
|
||||||
signalType: SignalType.EXPANSION,
|
signalType: SignalType.EXPANSION,
|
||||||
@@ -491,154 +501,186 @@ export const mockFutureSignals: FutureSignal[] = [
|
|||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
createdAt: '2025-04-02T09:00:00Z',
|
createdAt: '2025-04-02T09:00:00Z',
|
||||||
updatedAt: '2025-05-08T10:00:00Z',
|
updatedAt: '2025-05-08T10:00:00Z',
|
||||||
aiSummary: 'Novabio Pharma AG kommuniziert im Geschäftsbericht 2024 eine "strategische Kapazitätserweiterung im Forschungsbereich Basel-Allschwil bis 2026". 6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil identifiziert. Potenzieller Mieter für Labor-/Büroflächen im Life-Sciences-Korridor.',
|
aiSummary: 'Novabio Pharma AG kommuniziert im Geschäftsbericht 2024 explizit eine «strategische Kapazitätserweiterung im Forschungsbereich Basel-Allschwil bis 2026». Parallel wurden 6 Inserate für Senior Scientists und Lab-Manager mit Standortangabe Allschwil identifiziert. Das Signal ist qualitativ stark: nicht Headcount-getrieben, sondern strategisch kommuniziert. Typischer Laborbedarf für diese Teamgrösse: 400–500 m² Labor + 200–250 m² Büro.',
|
||||||
marketIndicators: [
|
marketIndicators: [
|
||||||
'Geschäftsbericht 2024: «Kapazitätserweiterung Basel-Allschwil bis 2026» explizit erwähnt',
|
'Geschäftsbericht 2024: «Kapazitätserweiterung Basel-Allschwil bis 2026» explizit erwähnt',
|
||||||
'6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil in 60 Tagen',
|
'6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil in 60 Tagen',
|
||||||
'Life-Sciences-Korridor Basel-Allschwil: höchste CH-weite Laborflächen-Nachfrage 2024',
|
'Life-Sciences-Korridor Basel-Allschwil: höchste CH-weite Laborflächen-Nachfrage 2024',
|
||||||
],
|
],
|
||||||
strategicInterpretation: 'Novabio Pharma AG kommuniziert Expansion öffentlich — Flächensuche sehr wahrscheinlich aktiv. Fokus auf Laborpersonal deutet auf kombinierten Lab-/Bürobedarf von 600–800 m² hin.',
|
strategicInterpretation: 'Expansion öffentlich kommuniziert — Flächensuche sehr wahrscheinlich bereits aktiv. Kombination aus publizierter Strategie und Laborpersonal-Inseraten ergibt starkes Signal für Lab-/Bürobedarf von 600–800 m².',
|
||||||
confirmedFacts: ['Expansionspläne im Geschäftsbericht bestätigt', 'Region Basel-Allschwil als Zielstandort genannt', 'Einstellungstrend seit Q1 2025 sichtbar'],
|
confirmedFacts: ['Expansionspläne im Geschäftsbericht bestätigt', 'Region Basel-Allschwil als Zielstandort genannt', 'Einstellungstrend seit Q1 2025 sichtbar'],
|
||||||
unconfirmedFacts: ['Ob Neubau oder Bestandsfläche gesucht', 'Exakter Flächenbedarf Labor vs. Büro', 'Zeitpunkt des Einzugs'],
|
unconfirmedFacts: ['Ob Neubau oder Bestandsfläche gesucht', 'Exakter Flächenbedarf Labor vs. Büro', 'Zeitpunkt des Einzugs'],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://novabio.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Dr. Anna Hoffmann (CFO)', confidence: 'HIGH', label: 'Aus Geschäftsbericht 2024' },
|
||||||
|
{ type: 'EMAIL', value: 'expansion@novabio.ch', confidence: 'LOW', label: 'Spekulativ — nicht bestätigt' },
|
||||||
|
{ type: 'PHONE', value: '+41 61 200 34 56', confidence: 'MEDIUM', label: 'Hauptnummer laut Website' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// signal-007: SwissCart E-Commerce GmbH — Logistikexpansion Zug
|
// signal-007: EBH Treuhand AG — Fusion zweier Winterthurer Büros
|
||||||
|
// Signal type: Merger → consolidation → need for larger single space.
|
||||||
|
// NOT hiring. Trigger is Handelsregister entry showing new merged entity.
|
||||||
{
|
{
|
||||||
id: 'signal-007',
|
id: 'signal-007',
|
||||||
signalType: SignalType.EXPANSION,
|
signalType: SignalType.EXPANSION,
|
||||||
signalDirection: 'DEMAND',
|
signalDirection: 'DEMAND',
|
||||||
companyName: 'SwissCart E-Commerce GmbH',
|
companyName: 'EBH Treuhand AG',
|
||||||
locationHint: 'Zug, Industriestrasse',
|
locationHint: 'Winterthur, Zentrum',
|
||||||
title: 'SwissCart E-Commerce GmbH sucht ~580 m² Logistikfläche in Zug',
|
title: 'EBH Treuhand AG — Fusion zweier Büros, konsolidierter Standort gesucht',
|
||||||
areaSqmEstimate: 580,
|
areaSqmEstimate: 450,
|
||||||
probability: 0.66,
|
probability: 0.71,
|
||||||
confidenceScore: 0.62,
|
confidenceScore: 0.67,
|
||||||
timeHorizonMonths: 9,
|
timeHorizonMonths: 7,
|
||||||
source: {
|
source: {
|
||||||
type: 'JOB_POSTING',
|
type: 'MARKET_DATA',
|
||||||
url: 'https://example.com/jobs/swisscart-zug',
|
url: 'https://example.com/handelsregister/ebh-fusion',
|
||||||
publishedAt: '2025-04-18',
|
publishedAt: '2025-04-02',
|
||||||
credibility: 'MEDIUM',
|
credibility: 'HIGH',
|
||||||
},
|
},
|
||||||
sensitivityLevel: 'INTERNAL',
|
sensitivityLevel: 'INTERNAL',
|
||||||
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
disclaimer: 'Signal aus Handelsregistereintrag. Fusion bestätigt — Flächenbedarf abgeleitet. Kein bestätigtes Mietverhältnis.',
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.LOW,
|
||||||
marketIndicator: 'E-Commerce Zug: Stellenwachstum +55% YoY',
|
marketIndicator: 'Treuhand-Fusionen Zürich/Winterthur 2024–2025: +18% gegenüber Vorjahr',
|
||||||
relevanceScore: 0.69,
|
relevanceScore: 0.74,
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
expiresAt: '2026-02-01',
|
expiresAt: '2026-01-01',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
createdAt: '2025-04-20T07:00:00Z',
|
createdAt: '2025-04-05T08:00:00Z',
|
||||||
updatedAt: '2025-05-09T08:00:00Z',
|
updatedAt: '2025-05-10T09:00:00Z',
|
||||||
aiSummary: 'In 45 Tagen wurden 9 Stelleninserate für Lager- und Logistikpersonal von SwissCart E-Commerce GmbH in Zug identifiziert. E-Commerce-Umsatz CH 2024 +22% — Nachfrage nach Lagerflächen steigt. Potenzieller Mieter für Logistikflächen in der Region Zug.',
|
aiSummary: 'Das Handelsregister Kanton Zürich zeigt am 2. April 2025 die Fusion von «Eichenberger Treuhand GmbH» (200 m², Stadthausstrasse) und «Bretscher & Huber Treuhand AG» (220 m², Tösstalstrasse) zur neuen «EBH Treuhand AG». Beide Einzelfirmen haben separate Mietverträge, die KI auf 2019 bzw. 2021 datiert — typische 6-Jahres-Laufzeiten enden 2025 bzw. 2027. Die fusionierte Entität (ca. 28 Mitarbeitende) benötigt einen gemeinsamen neuen Standort von ~400–500 m². Die Entscheidung dürfte zeitnah fallen.',
|
||||||
marketIndicators: [
|
marketIndicators: [
|
||||||
'9 Stelleninserate für Lager- und Logistikpersonal in Zug in 45 Tagen',
|
'Handelsregistereintrag: Fusion zu EBH Treuhand AG am 2. April 2025 bestätigt',
|
||||||
'E-Commerce-Umsatz CH 2024: +22% — Nachfrage nach Lagerflächen steigt',
|
'Kombinierte Teamgrösse ca. 28 Personen — bisherige Flächen je ~200 m² nicht mehr ausreichend',
|
||||||
'Zug Industriestrasse: aktuelle Leerstandsquote Lager unter 3%',
|
'Treuhand-Fusionen Winterthur 2024: +18% — Konsolidierungsdruck durch Digitalisierung',
|
||||||
|
],
|
||||||
|
strategicInterpretation: 'Fusion ist der stärkste mögliche Indikator für Flächenbedarf — eine neue juristische Einheit muss zwingend einen gemeinsamen Standort finden. Zeitdruck durch auslaufende Altmietverträge. Winterthur Zentrum bevorzugt für Kundenfrequenz.',
|
||||||
|
confirmedFacts: ['Fusion im Handelsregister bestätigt', 'Kombinierte Teamgrösse ca. 28 Personen bekannt', 'Beide Altstandorte bekannt'],
|
||||||
|
unconfirmedFacts: ['Ob einer der Altstandorte weitergeführt wird', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://ebh-treuhand.ch', confidence: 'HIGH', label: 'Neue Unternehmenswebsite' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Thomas Eichenberger (Geschäftsführer)', confidence: 'HIGH', label: 'Aus Handelsregistereintrag' },
|
||||||
|
{ type: 'EMAIL', value: 'info@ebh-treuhand.ch', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
|
||||||
|
{ type: 'PHONE', value: '+41 52 212 44 00', confidence: 'MEDIUM', label: 'Bisherige Eichenberger-Nummer' },
|
||||||
],
|
],
|
||||||
strategicInterpretation: 'Massiver Personalaufbau im Logistikbereich deutet auf konkreten Flächenbedarf hin. Zug als Steuerkanton bleibt attraktiver Firmensitz — kein Standortwechsel erwartet, nur Erweiterung.',
|
|
||||||
confirmedFacts: ['Region Zug aus Inseraten bestätigt', 'Flächentyp Logistik/Lager aus Stellenprofilen ableitbar', 'Wachstumstrend seit Q3 2024 sichtbar'],
|
|
||||||
unconfirmedFacts: ['Ob zusätzliche Bürofläche benötigt', 'Exakter Flächenbedarf', 'Bevorzugte Strassenlage (Anlieferung)'],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// signal-010: Ostschweiz Digital AG — Büroexpansion St. Gallen
|
// signal-010: Centra Schweiz AG — Standortverlagerung Schlieren → Zürich Innenstadt
|
||||||
|
// Signal type: Rightsizing / relocation — company moving from suburban to central location
|
||||||
|
// for talent attraction. NOT about more space — about better location.
|
||||||
{
|
{
|
||||||
id: 'signal-010',
|
id: 'signal-010',
|
||||||
signalType: SignalType.EXPANSION,
|
signalType: SignalType.EXPANSION,
|
||||||
signalDirection: 'DEMAND',
|
signalDirection: 'DEMAND',
|
||||||
companyName: 'Ostschweiz Digital AG',
|
companyName: 'Centra Schweiz AG',
|
||||||
locationHint: 'St. Gallen, Riethüsli',
|
locationHint: 'Zürich, Kreis 4 / 5',
|
||||||
title: 'Ostschweiz Digital AG sucht ~480 m² Bürofläche in St. Gallen',
|
title: 'Centra Schweiz AG verlagert Standort von Schlieren in Zürich Innenstadt',
|
||||||
areaSqmEstimate: 480,
|
areaSqmEstimate: 380,
|
||||||
probability: 0.60,
|
probability: 0.66,
|
||||||
confidenceScore: 0.55,
|
confidenceScore: 0.62,
|
||||||
timeHorizonMonths: 10,
|
timeHorizonMonths: 11,
|
||||||
source: {
|
source: {
|
||||||
type: 'JOB_POSTING',
|
type: 'PRESS',
|
||||||
url: 'https://example.com/jobs/ostschweiz-digital',
|
url: 'https://example.com/news/centra-standortverlagerung',
|
||||||
publishedAt: '2025-04-22',
|
publishedAt: '2025-04-10',
|
||||||
credibility: 'MEDIUM',
|
credibility: 'MEDIUM',
|
||||||
},
|
},
|
||||||
sensitivityLevel: 'INTERNAL',
|
sensitivityLevel: 'INTERNAL',
|
||||||
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
disclaimer: 'Signal aus Pressemitteilung und LinkedIn-Jobinseraten. Verlagerungsentscheid kommuniziert — Flächensuche wahrscheinlich aktiv.',
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
marketIndicator: 'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024',
|
marketIndicator: 'Zürich Kreis 5: Neuzuzüge Büronutzer +21% 2024',
|
||||||
relevanceScore: 0.62,
|
relevanceScore: 0.67,
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
expiresAt: '2026-03-01',
|
expiresAt: '2026-04-01',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
createdAt: '2025-04-25T08:00:00Z',
|
createdAt: '2025-04-12T08:00:00Z',
|
||||||
updatedAt: '2025-05-10T07:00:00Z',
|
updatedAt: '2025-05-10T07:00:00Z',
|
||||||
aiSummary: '8 Stelleninserate für Software-Entwickler und UX-Designer in St. Gallen in 60 Tagen. Mitarbeiterzahl von 22 auf 31 (+41%) in 9 Monaten. Kontinuierliches Wachstum deutet auf Bedarf nach grösserer Bürofläche hin — potenzieller Mieter für Büroflächen in St. Gallen.',
|
aiSummary: 'Centra Schweiz AG (IT-Beratung, aktuell Schlieren, ~35 Mitarbeitende) hat in einer Medienmitteilung «eine Standortoptimierung zur Verbesserung der Mitarbeitergewinnung» angekündigt. Konkret: Verlagerung von Schlieren nach Zürich Innenstadt. Die Fläche wird nicht grösser — sie wird urbaner. KI identifizierte 4 aktuelle Stelleninserate, die explizit «Zürich» als Arbeitsort nennen, obwohl die Firma noch in Schlieren domiziliert ist. Das deutet auf einen laufenden Standortwechsel hin.',
|
||||||
marketIndicators: [
|
marketIndicators: [
|
||||||
'8 Stelleninserate für Software-Entwickler und UX-Designer in St. Gallen in 60 Tagen',
|
'Medienmitteilung: Verlagerung Schlieren → Zürich Innenstadt offiziell angekündigt',
|
||||||
'Ostschweiz Digital AG: Mitarbeiterzahl von 22 auf 31 (+41%) in 9 Monaten',
|
'4 Stelleninserate nennen «Zürich» als Arbeitsort trotz aktuellem Schlieren-Domizil',
|
||||||
'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024 (IHK-Bericht)',
|
'Zürich Kreis 4/5: Neuzuzüge von Tech- und Beratungsunternehmen +21% 2024',
|
||||||
|
],
|
||||||
|
strategicInterpretation: 'Standortverlagerung zur Talentgewinnung — kein Wachstum, sondern Lageoptimierung. Gesuchte Fläche ähnlich gross wie aktuell (~350–400 m²), aber moderner Ausbaustandard und ÖV-Nähe erwartet. Entscheid gefallen, Flächensuche aktiv.',
|
||||||
|
confirmedFacts: ['Verlagerungsentscheid aus Medienmitteilung bestätigt', 'Zielgebiet Zürich Innenstadt kommuniziert', 'Aktuelle Fläche Schlieren ~380 m²'],
|
||||||
|
unconfirmedFacts: ['Exakter Zielbezirk (Kreis 4 vs. 5)', 'Ob Fläche gleichgross oder kleiner', 'Einzugstermin'],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://centra-schweiz.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Nicole Baumann (CEO)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
|
||||||
|
{ type: 'EMAIL', value: 'n.baumann@centra-schweiz.ch', confidence: 'MEDIUM', label: 'Aus Stelleninserat-Signatur' },
|
||||||
|
{ type: 'LINKEDIN', value: 'linkedin.com/company/centra-schweiz', confidence: 'HIGH', label: 'LinkedIn Firmenprofil' },
|
||||||
],
|
],
|
||||||
strategicInterpretation: 'Kontinuierliches Teamwachstum mit Fokus auf St. Gallen deutet auf organische Expansion hin. Erweiterungsfläche am bisherigen Standort oder Umzug in grössere Einheit möglich.',
|
|
||||||
confirmedFacts: ['Region St. Gallen aus Inseraten bestätigt', 'Wachstumstrend seit Q3 2024 nachweisbar', 'Flächentyp Büro aus Stellenprofilen ableitbar'],
|
|
||||||
unconfirmedFacts: ['Ob aktueller Standort Riethüsli oder neue Lage', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// signal-013: Geneva Finance Partners SA — Büroexpansion Genf
|
// signal-013: Rheintaler Spedition AG — Spin-off braucht eigenen Standort
|
||||||
|
// Signal type: Corporate spin-off — new legal entity split from parent, needs its own space.
|
||||||
|
// NOT organic growth. Trigger is Handelsregister + parent company press release.
|
||||||
{
|
{
|
||||||
id: 'signal-013',
|
id: 'signal-013',
|
||||||
signalType: SignalType.EXPANSION,
|
signalType: SignalType.EXPANSION,
|
||||||
signalDirection: 'DEMAND',
|
signalDirection: 'DEMAND',
|
||||||
companyName: 'Geneva Finance Partners SA',
|
companyName: 'Rheintaler Logistik Solutions AG',
|
||||||
locationHint: 'Genf, La Praille',
|
locationHint: 'St. Gallen / Rheintal',
|
||||||
title: 'Geneva Finance Partners SA sucht ~520 m² Bürofläche in Genf La Praille',
|
title: 'Rheintaler Logistik Solutions AG — Spin-off sucht eigenständigen Standort',
|
||||||
areaSqmEstimate: 520,
|
areaSqmEstimate: 680,
|
||||||
probability: 0.58,
|
probability: 0.69,
|
||||||
confidenceScore: 0.53,
|
confidenceScore: 0.65,
|
||||||
timeHorizonMonths: 20,
|
timeHorizonMonths: 9,
|
||||||
source: {
|
source: {
|
||||||
type: 'COMPANY_REPORT',
|
type: 'MARKET_DATA',
|
||||||
url: 'https://example.com/annual/gfp-2025',
|
url: 'https://example.com/handelsregister/rheintaler-spinoff',
|
||||||
publishedAt: '2025-03-05',
|
publishedAt: '2025-03-20',
|
||||||
credibility: 'MEDIUM',
|
credibility: 'HIGH',
|
||||||
},
|
},
|
||||||
sensitivityLevel: 'INTERNAL',
|
sensitivityLevel: 'INTERNAL',
|
||||||
disclaimer: 'Erkannte Flächennachfrage aus Jahresbericht. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
disclaimer: 'Signal aus Handelsregistereintrag und Pressemitteilung der Muttergesellschaft. Neue juristische Einheit — eigenständiger Standort notwendig.',
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.LOW,
|
||||||
marketIndicator: 'Büroflächennachfrage Genf: +15% 2025',
|
marketIndicator: 'Logistik Ostschweiz: Nachfrage nach kombinierten Büro-/Gewerbeflächen stabil',
|
||||||
relevanceScore: 0.60,
|
relevanceScore: 0.72,
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
expiresAt: '2027-01-01',
|
expiresAt: '2026-02-01',
|
||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
createdAt: '2025-03-08T11:00:00Z',
|
createdAt: '2025-03-25T09:00:00Z',
|
||||||
updatedAt: '2025-05-07T10:00:00Z',
|
updatedAt: '2025-05-08T10:00:00Z',
|
||||||
aiSummary: 'Jahresbericht 2024: «Expansion des Genfer Teams auf 40 FTE bis 2026» angekündigt. 5 neue Inserate für Senior-Portfoliomanager mit Standort Genf in 30 Tagen. Potenzieller Mieter für Büroflächen in Genf La Praille.',
|
aiSummary: 'Die Muttergesellschaft «Rheintaler Transport AG» hat am 20. März im Handelsregister die Ausgliederung des Bereichs Logistik-Software in eine eigenständige Tochter «Rheintaler Logistik Solutions AG» eingetragen. Medienmitteilung: «Die neue Einheit agiert ab Q3 2025 vollständig selbständig». Spin-offs dieser Art benötigen zwingend einen eigenen Standort — sie können nicht auf der Mutteradresse firmieren, wenn sie an Kunden pitchen. Typischer Bedarf: 400–500 m² Büro + 150–200 m² kleines Lager/Technikraum.',
|
||||||
marketIndicators: [
|
marketIndicators: [
|
||||||
'Jahresbericht 2024: «Expansion des Genfer Teams auf 40 FTE bis 2026» angekündigt',
|
'Handelsregistereintrag: neue AG «Rheintaler Logistik Solutions AG» per 20. März',
|
||||||
'Genf Finanzsektor: +15% Büroflächennachfrage 2025 (CBRE-Bericht)',
|
'Medienmitteilung: «Vollständige operative Selbständigkeit ab Q3 2025»',
|
||||||
'5 neue Inserate für Senior-Portfoliomanager mit Standort Genf in 30 Tagen',
|
'Logistik-Software Ostschweiz: Nachfrage nach Gewerbeflächen mit Büro wächst',
|
||||||
|
],
|
||||||
|
strategicInterpretation: 'Spin-off ist ein sehr starkes Flächensignal — neue juristische Einheit kann nicht auf der Mutteradresse verbleiben, wenn sie selbständig am Markt auftritt. Zeitdruck durch angekündigte Q3-Selbständigkeit. Kombination Büro + kleines Lager typisch für Logistik-Software-Firmen.',
|
||||||
|
confirmedFacts: ['Handelsregistereintrag neue AG bestätigt', 'Selbständigkeit ab Q3 2025 kommuniziert', 'Muttergesellschaft Standort Rheintal bekannt'],
|
||||||
|
unconfirmedFacts: ['Ob im Rheintal bleiben oder nach St. Gallen', 'Exakter Lagerbedarf', 'Budget pro m²'],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://rheintaler-logistik-solutions.ch', confidence: 'MEDIUM', label: 'Neue Website (noch im Aufbau)' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Florian Zäch (CEO Spin-off)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
|
||||||
|
{ type: 'EMAIL', value: 'f.zaech@rheintaler-ls.ch', confidence: 'LOW', label: 'Spekulativ — neue Domain' },
|
||||||
|
{ type: 'PHONE', value: '+41 71 750 33 00', confidence: 'MEDIUM', label: 'Bisherige Mutternummer' },
|
||||||
],
|
],
|
||||||
strategicInterpretation: 'Öffentlich kommunizierte Expansion mit klarem Zielhorizont 2026. La Praille bietet kostengünstigere Büroflächen als Genf Innenstadt bei guter ÖV-Anbindung. Flächensuche dürfte aktiv beginnen.',
|
|
||||||
confirmedFacts: ['Expansion im Jahresbericht kommuniziert', 'Teamwachstum auf 40 FTE bis 2026 bestätigt', 'Inserate für Genf aktiv'],
|
|
||||||
unconfirmedFacts: ['Ob La Praille oder andere Lage bevorzugt', 'Exakter Flächenbedarf', 'Ob Repräsentationsbüros benötigt'],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// signal-015: BernTech Innovation AG — Büroexpansion Bern Breitenrain
|
// signal-015: BernTech Innovation AG — VC-Finanzierung, Einzug neuer Standort Bern
|
||||||
|
// Signal type: Venture funding → company outgrows current space.
|
||||||
|
// Clear signal: public funding round announcement with explicit growth roadmap.
|
||||||
{
|
{
|
||||||
id: 'signal-015',
|
id: 'signal-015',
|
||||||
signalType: SignalType.EXPANSION,
|
signalType: SignalType.EXPANSION,
|
||||||
signalDirection: 'DEMAND',
|
signalDirection: 'DEMAND',
|
||||||
companyName: 'BernTech Innovation AG',
|
companyName: 'BernTech Innovation AG',
|
||||||
locationHint: 'Bern, Breitenrain',
|
locationHint: 'Bern, Breitenrain / Lorraine',
|
||||||
title: 'BernTech Innovation AG sucht ~1\'800 m² Bürofläche in Bern Breitenrain',
|
title: 'BernTech Innovation AG — CHF 12 Mio. Finanzierungsrunde, Standortsuche Bern',
|
||||||
areaSqmEstimate: 1800,
|
areaSqmEstimate: 1800,
|
||||||
probability: 0.68,
|
probability: 0.68,
|
||||||
confidenceScore: 0.64,
|
confidenceScore: 0.64,
|
||||||
timeHorizonMonths: 16,
|
timeHorizonMonths: 16,
|
||||||
source: {
|
source: {
|
||||||
type: 'JOB_POSTING',
|
type: 'PRESS',
|
||||||
url: 'https://example.com/jobs/berntech',
|
url: 'https://example.com/news/berntech-finanzierung-2025',
|
||||||
publishedAt: '2025-04-25',
|
publishedAt: '2025-03-18',
|
||||||
credibility: 'MEDIUM',
|
credibility: 'HIGH',
|
||||||
},
|
},
|
||||||
sensitivityLevel: 'INTERNAL',
|
sensitivityLevel: 'INTERNAL',
|
||||||
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten und VC-Finanzierungsdaten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
disclaimer: 'Erkannte Flächennachfrage aus VC-Finanzierungsdaten und Pressebericht. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||||
riskLevel: RiskLevel.MEDIUM,
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
marketIndicator: 'Bern Tech-Ökosystem: Risikokapital +40% 2024',
|
marketIndicator: 'Bern Tech-Ökosystem: Risikokapital +40% 2024',
|
||||||
relevanceScore: 0.66,
|
relevanceScore: 0.66,
|
||||||
@@ -647,14 +689,20 @@ export const mockFutureSignals: FutureSignal[] = [
|
|||||||
organizationId: 'org-wincasa',
|
organizationId: 'org-wincasa',
|
||||||
createdAt: '2025-04-28T09:00:00Z',
|
createdAt: '2025-04-28T09:00:00Z',
|
||||||
updatedAt: '2025-05-10T08:00:00Z',
|
updatedAt: '2025-05-10T08:00:00Z',
|
||||||
aiSummary: '18 Stelleninserate in 60 Tagen (Fokus Software-Engineering und Product Management). VC-Finanzierungsrunde CHF 12 Mio. im März 2025 öffentlich bestätigt. Frisch finanziertes Wachstumsunternehmen mit Budget für Flächenexpansion — potenzieller Mieter für Büroflächen in Bern.',
|
aiSummary: 'BernTech Innovation AG hat am 18. März eine VC-Finanzierungsrunde von CHF 12 Mio. öffentlich bestätigt (Medien: Startupticker, Handelszeitung). Im Pressebericht explizit: «Die Mittel fliessen in Produktentwicklung, Infrastruktur und ein neues Büro in Bern». Das ist kein abgeleitetes Signal — der CEO hat den Flächenbedarf selbst kommuniziert. Aktuelle Fläche: ~450 m² in Bern Lorraine. Zielgrösse für nächste Phase: 1\'500–2\'000 m².',
|
||||||
marketIndicators: [
|
marketIndicators: [
|
||||||
'18 Stelleninserate in 60 Tagen — Fokus Software-Engineering und Product Management',
|
'VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt (Startupticker, Handelszeitung)',
|
||||||
'BernTech Innovation AG: VC-Finanzierungsrunde CHF 12 Mio. im März 2025 bestätigt',
|
'CEO-Zitat: «Neues Büro in Bern» explizit als Verwendungszweck kommuniziert',
|
||||||
'Bern Tech-Ökosystem: Risikokapital +40% 2024 — stärkstes Wachstum seit 2019',
|
'Bern Tech-Ökosystem: Risikokapital +40% 2024 — stärkstes Wachstum seit 2019',
|
||||||
],
|
],
|
||||||
strategicInterpretation: 'Frisch finanziertes Wachstumsunternehmen mit aktivem Stellenaufbau und gesichertem Budget. VC-Finanzierung als starkes Signal für konkreten Flächenbedarf. Breitenrain als Tech-Standort in Bern etabliert.',
|
strategicInterpretation: 'CEO hat den Flächenbedarf selbst öffentlich kommuniziert — das ist das stärkste mögliche Signal. Budget vorhanden, Standort Bern bestätigt, Suche aktiv. Breitenrain und Lorraine als etablierte Tech-Quartiere in Bern bevorzugt.',
|
||||||
confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Region Bern aus Inseraten bestätigt', 'Wachstumstrend seit Q1 2025 sichtbar'],
|
confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Neues Büro Bern vom CEO kommuniziert', 'Aktueller Standort Lorraine ~450 m²'],
|
||||||
unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung: 1\'500–2\'000 m²)', 'Ob Breitenrain oder andere Berner Lage bevorzugt', 'Zeitpunkt Einzug'],
|
unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung 1\'500–2\'000 m²)', 'Ob Breitenrain oder andere Berner Lage', 'Einzugstermin'],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://berntech.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||||
|
{ type: 'EMAIL', value: 'hello@berntech.ch', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Stefan Leuenberger (CEO)', confidence: 'HIGH', label: 'LinkedIn + Pressebericht' },
|
||||||
|
{ type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -146,6 +146,226 @@ export const mockLatentNeeds: LatentNeed[] = [
|
|||||||
createdAt: '2026-05-09T08:15:00Z',
|
createdAt: '2026-05-09T08:15:00Z',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Suchabos linked to direct listing matches ────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-007',
|
||||||
|
title: 'Modernes Büro in Zürich für 600–1000m²',
|
||||||
|
tenantCompany: 'Innovatech AG',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
desiredLocation: 'Zürich-West / Kreis 5',
|
||||||
|
sizeRange: { min: 600, max: 1000 },
|
||||||
|
budgetRange: { min: 32, max: 45 },
|
||||||
|
timing: 'Bezug ab Herbst 2025, flexibel',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'ÖV-Anbindung unter 6 Min',
|
||||||
|
'Mindestfläche 600m²',
|
||||||
|
'Moderner Ausbau',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Lage Zürich-West', weight: 0.35 },
|
||||||
|
{ criterion: 'Ausbaustandard', weight: 0.25, description: 'Modern, repräsentativ' },
|
||||||
|
{ criterion: 'Erweiterungsoption', weight: 0.20 },
|
||||||
|
{ criterion: 'ESG-Standard', weight: 0.10 },
|
||||||
|
{ criterion: 'Parkplätze', weight: 0.10 },
|
||||||
|
],
|
||||||
|
aiSummary: 'Technologieunternehmen mit solidem Wachstum sucht Bürofläche in Zürich-West. Hohe Abschlussbereitschaft bei passendem Objekt.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-04-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-008',
|
||||||
|
title: 'Logistikfläche Basel-Region 1500–4000m²',
|
||||||
|
tenantCompany: 'Schweizer Logistik GmbH',
|
||||||
|
assetType: AssetType.LOGISTICS,
|
||||||
|
desiredLocation: 'Basel / Muttenz / Pratteln / Reinach BL',
|
||||||
|
sizeRange: { min: 1500, max: 4000 },
|
||||||
|
budgetRange: { min: 12, max: 18 },
|
||||||
|
timing: 'Bezug ab September 2025',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'Autobahnanschluss unter 5 Min',
|
||||||
|
'Rampe / Andienung vorhanden',
|
||||||
|
'Hallenhöhe min 6m',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Rampenanzahl', weight: 0.30 },
|
||||||
|
{ criterion: 'Lkw-Wendekreis', weight: 0.25 },
|
||||||
|
{ criterion: 'Erweiterungspotenzial', weight: 0.20 },
|
||||||
|
{ criterion: '24/7-Betrieb', weight: 0.15 },
|
||||||
|
{ criterion: 'Sprinkleranlage', weight: 0.10 },
|
||||||
|
],
|
||||||
|
aiSummary: 'Etablierter Logistiker mit klaren Anforderungen. Region Basel priorisiert. Sehr gute Matchbasis mit verfügbaren Hallenflächen.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-03-20T14:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-009',
|
||||||
|
title: 'Premium-Retailfläche Zürich Innenstadt',
|
||||||
|
tenantCompany: 'Retailer Zürich AG',
|
||||||
|
assetType: AssetType.RETAIL,
|
||||||
|
desiredLocation: 'Zürich Innenstadt / Bahnhofstrasse / Niederdorf',
|
||||||
|
sizeRange: { min: 200, max: 500 },
|
||||||
|
budgetRange: { min: 60, max: 100 },
|
||||||
|
timing: 'Bezug ab September 2025, hohe Dringlichkeit',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'Laufkundschaft und Passantenfrequenz',
|
||||||
|
'Schaufensterfront',
|
||||||
|
'Erdgeschoss',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Fussgängerfrequenz', weight: 0.35 },
|
||||||
|
{ criterion: 'Lagequalität', weight: 0.30, description: 'Zürich Innenstadt' },
|
||||||
|
{ criterion: 'Sichtbarkeit', weight: 0.20 },
|
||||||
|
{ criterion: 'Deckenhöhe', weight: 0.10 },
|
||||||
|
{ criterion: 'Lagerfläche', weight: 0.05 },
|
||||||
|
],
|
||||||
|
aiSummary: 'Retailer mit starkem Markenbewusstsein sucht Toplage in Zürich. Klare Präferenz Innenstadt, solider Abschluss bei Matching.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-03-10T11:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-010',
|
||||||
|
title: 'Büro 300–700m² für Tech-Startup Zug/Zürich',
|
||||||
|
tenantCompany: 'TechStart GmbH',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
desiredLocation: 'Zug / Zürich / Baar',
|
||||||
|
sizeRange: { min: 300, max: 700 },
|
||||||
|
budgetRange: { min: 30, max: 48 },
|
||||||
|
timing: 'Bezug ab Oktober 2025, flexibel bis Q2 2026',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'Moderner Ausbau',
|
||||||
|
'Schnelles Internet / Glasfaser',
|
||||||
|
'Fahrradabstellplätze',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Tech-Umfeld / Startup-Nähe', weight: 0.30 },
|
||||||
|
{ criterion: 'ÖV-Anbindung', weight: 0.25 },
|
||||||
|
{ criterion: 'Flexibler Grundriss', weight: 0.20 },
|
||||||
|
{ criterion: 'Steuerumfeld', weight: 0.15, description: 'Zug bevorzugt' },
|
||||||
|
{ criterion: 'ESG-Qualität', weight: 0.10 },
|
||||||
|
],
|
||||||
|
aiSummary: 'Wachsendes Tech-Startup mit flexibler Lage zwischen Zug und Zürich. Hohe Bereitschaft bei modernem Objekt mit gutem Preis-Leistungs-Verhältnis.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-04-28T13:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-011',
|
||||||
|
title: 'Gewerbe-/Lagerfläche Pratteln 400–750m²',
|
||||||
|
tenantCompany: 'Gewerbe Solutions AG',
|
||||||
|
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||||
|
desiredLocation: 'Pratteln / Muttenz / Schweizerhalle',
|
||||||
|
sizeRange: { min: 400, max: 750 },
|
||||||
|
budgetRange: { min: 10, max: 14 },
|
||||||
|
timing: 'Bezug ab April 2026',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'Lkw-Zufahrt',
|
||||||
|
'Hallenhöhe min 5m',
|
||||||
|
'Drehstrom vorhanden',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Autobahnnähe', weight: 0.30 },
|
||||||
|
{ criterion: 'Aussenanlagefläche', weight: 0.25 },
|
||||||
|
{ criterion: 'Erweiterungspotenzial', weight: 0.20 },
|
||||||
|
{ criterion: 'Parkplätze', weight: 0.15 },
|
||||||
|
{ criterion: 'Büroanteil', weight: 0.10, description: 'Kleines Büro on-site gewünscht' },
|
||||||
|
],
|
||||||
|
aiSummary: 'Gewerbebetrieb mit klaren Anforderungen sucht Fläche im Raum Pratteln. Gute Ausgangslage für verfügbare Gewerbeliegenschaften.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-03-01T09:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-012',
|
||||||
|
title: 'Büro 180–320m² Bern Zentrum / Bahnhof',
|
||||||
|
tenantCompany: 'Bern Consulting AG',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
desiredLocation: 'Bern Bollwerk / Bahnhof / Innenstadt',
|
||||||
|
sizeRange: { min: 180, max: 320 },
|
||||||
|
budgetRange: { min: 20, max: 30 },
|
||||||
|
timing: 'Bezug ab Mai 2026',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'ÖV-Anbindung unter 5 Min zum Bahnhof',
|
||||||
|
'Repräsentative Lage',
|
||||||
|
'Konferenzraum',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Bahnhofsnähe', weight: 0.35, description: 'Kundenmeetings erfordern zentrale Lage' },
|
||||||
|
{ criterion: 'Ausbaustandard', weight: 0.25 },
|
||||||
|
{ criterion: 'Flexibilität', weight: 0.20 },
|
||||||
|
{ criterion: 'Natürliches Licht', weight: 0.10 },
|
||||||
|
{ criterion: 'IT-Infrastruktur', weight: 0.10 },
|
||||||
|
],
|
||||||
|
aiSummary: 'Beratungsunternehmen sucht repräsentatives Büro nahe Bern HB. Zentrale Lage ist entscheidend für Kundenkontakte. Guter Match mit Berner Citylagen.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-04-05T10:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-013',
|
||||||
|
title: 'Verkaufsfläche Winterthur Zentrum 150–250m²',
|
||||||
|
tenantCompany: 'Winterthur Handel GmbH',
|
||||||
|
assetType: AssetType.RETAIL,
|
||||||
|
desiredLocation: 'Winterthur Zentrum / Stadthausstrasse / Altstadt',
|
||||||
|
sizeRange: { min: 150, max: 250 },
|
||||||
|
budgetRange: { min: 25, max: 40 },
|
||||||
|
timing: 'Bezug ab Juni 2026',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'Schaufensterfront',
|
||||||
|
'Erdgeschoss',
|
||||||
|
'Fussgängerfrequenz',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Lage Innenstadt Winterthur', weight: 0.40 },
|
||||||
|
{ criterion: 'Schaufenstergrösse', weight: 0.25 },
|
||||||
|
{ criterion: 'Deckenhöhe', weight: 0.15 },
|
||||||
|
{ criterion: 'Lagerraum', weight: 0.10 },
|
||||||
|
{ criterion: 'Barrierefrei', weight: 0.10 },
|
||||||
|
],
|
||||||
|
aiSummary: 'Lokaler Handelsbetrieb sucht Fläche in Winterthur Innenstadt. Klare Anforderungen, realistisches Budget. Guter Match mit Stadtzentrum-Angeboten.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-03-20T11:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'lneed-014',
|
||||||
|
title: 'Bürofläche 350–600m² Luzern',
|
||||||
|
tenantCompany: 'Luzern Advisory GmbH',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
desiredLocation: 'Luzern / Luzern-West / Emmen',
|
||||||
|
sizeRange: { min: 350, max: 600 },
|
||||||
|
budgetRange: { min: 18, max: 26 },
|
||||||
|
timing: 'Bezug ab Juni 2026, flexibel bis Januar 2027',
|
||||||
|
mustHaveCriteria: [
|
||||||
|
'ÖV-Anbindung unter 10 Min',
|
||||||
|
'Moderner Ausbau',
|
||||||
|
'Parkplätze vorhanden',
|
||||||
|
],
|
||||||
|
weightedPreferences: [
|
||||||
|
{ criterion: 'Standortqualität Luzern', weight: 0.30 },
|
||||||
|
{ criterion: 'Ausbaustandard', weight: 0.25 },
|
||||||
|
{ criterion: 'Parkplatzverfügbarkeit', weight: 0.20 },
|
||||||
|
{ criterion: 'Erweiterungspotenzial', weight: 0.15 },
|
||||||
|
{ criterion: 'ESG', weight: 0.10 },
|
||||||
|
],
|
||||||
|
aiSummary: 'Beratungsgesellschaft sucht modernes Büro in Luzern. Gute Balance zwischen Lage, Qualität und Budget. Solides Matchprofil.',
|
||||||
|
publicVisibility: true,
|
||||||
|
status: 'public',
|
||||||
|
createdAt: '2026-04-10T14:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Original lneed-006 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'lneed-006',
|
id: 'lneed-006',
|
||||||
title: 'Pop-up-Retailfläche temporär 6 Monate',
|
title: 'Pop-up-Retailfläche temporär 6 Monate',
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { AssetType } from '../domain/enums'
|
||||||
|
import type { MarktHinweis } from '../domain/marktHinweis'
|
||||||
|
|
||||||
|
export const marktHinweise: MarktHinweis[] = [
|
||||||
|
{
|
||||||
|
id: 'mh-001',
|
||||||
|
createdAt: '2026-05-22T09:15:00Z',
|
||||||
|
createdBy: 'Thomas Müller',
|
||||||
|
verwaltungId: 'v-001',
|
||||||
|
verwaltungName: 'Wincasa AG',
|
||||||
|
direction: 'SUCHE',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
locationHint: 'Zürich Innenstadt',
|
||||||
|
areaSqmMin: 200,
|
||||||
|
areaSqmMax: 400,
|
||||||
|
companyName: 'Fintech Solutions AG',
|
||||||
|
isAnonymized: false,
|
||||||
|
quelle: 'NETZWERKEVENT',
|
||||||
|
note: 'Sucht modernes Büro, Nähe HB, max CHF 350/m²/J. Kontakt: CEO Andreas Baum',
|
||||||
|
visibility: 'INTERN',
|
||||||
|
status: 'OFFEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mh-002',
|
||||||
|
createdAt: '2026-05-21T14:30:00Z',
|
||||||
|
createdBy: 'Sarah Keller',
|
||||||
|
verwaltungId: 'v-001',
|
||||||
|
verwaltungName: 'Wincasa AG',
|
||||||
|
direction: 'SUCHE',
|
||||||
|
assetType: AssetType.LOGISTICS,
|
||||||
|
locationHint: 'Pratteln / Muttenz',
|
||||||
|
areaSqmMin: 800,
|
||||||
|
areaSqmMax: 1500,
|
||||||
|
companyName: 'Elektro Bernhard GmbH',
|
||||||
|
isAnonymized: false,
|
||||||
|
quelle: 'TELEFONAT',
|
||||||
|
note: 'Benötigt Lagerfläche mit Rampe und Kran. Einzug Q1/2027.',
|
||||||
|
visibility: 'INTERN',
|
||||||
|
status: 'OFFEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mh-003',
|
||||||
|
createdAt: '2026-05-20T11:00:00Z',
|
||||||
|
createdBy: 'Thomas Müller',
|
||||||
|
verwaltungId: 'v-001',
|
||||||
|
verwaltungName: 'Wincasa AG',
|
||||||
|
direction: 'VERFUEGBARKEIT',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
locationHint: 'Basel',
|
||||||
|
areaSqmMin: 350,
|
||||||
|
areaSqmMax: 350,
|
||||||
|
companyName: 'Fläche frei per Sept. 2026',
|
||||||
|
isAnonymized: false,
|
||||||
|
quelle: 'BESICHTIGUNG',
|
||||||
|
note: 'Bürofläche 350m², guter Zustand, 4. OG, Aufzug.',
|
||||||
|
visibility: 'INTERN',
|
||||||
|
status: 'OFFEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mh-004',
|
||||||
|
createdAt: '2026-05-19T08:45:00Z',
|
||||||
|
createdBy: 'Thomas Müller',
|
||||||
|
verwaltungId: 'v-001',
|
||||||
|
verwaltungName: 'Wincasa AG',
|
||||||
|
direction: 'SUCHE',
|
||||||
|
assetType: AssetType.RETAIL,
|
||||||
|
locationHint: 'Winterthur Altstadt',
|
||||||
|
areaSqmMin: 80,
|
||||||
|
areaSqmMax: 150,
|
||||||
|
companyName: 'Boutique-Kette',
|
||||||
|
isAnonymized: false,
|
||||||
|
quelle: 'MESSE',
|
||||||
|
note: 'Ladenfläche EG, Fussgängerzone bevorzugt.',
|
||||||
|
visibility: 'PLATTFORM',
|
||||||
|
status: 'OFFEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mh-005',
|
||||||
|
createdAt: '2026-05-18T16:20:00Z',
|
||||||
|
createdBy: 'Petra Züst',
|
||||||
|
verwaltungId: 'v-002',
|
||||||
|
verwaltungName: 'Livit AG',
|
||||||
|
direction: 'SUCHE',
|
||||||
|
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||||
|
locationHint: 'Raum Bern',
|
||||||
|
areaSqmMin: 400,
|
||||||
|
areaSqmMax: 700,
|
||||||
|
isAnonymized: true,
|
||||||
|
quelle: 'TELEFONAT',
|
||||||
|
note: 'Produktionsfirma sucht Gewerbehalle, Deckenhöhe mind. 5m.',
|
||||||
|
visibility: 'PLATTFORM',
|
||||||
|
status: 'OFFEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mh-006',
|
||||||
|
createdAt: '2026-05-17T10:30:00Z',
|
||||||
|
createdBy: 'Marco Lüthi',
|
||||||
|
verwaltungId: 'v-003',
|
||||||
|
verwaltungName: 'Zurich Invest AG',
|
||||||
|
direction: 'VERFUEGBARKEIT',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
locationHint: 'Zürich West',
|
||||||
|
areaSqmMin: 550,
|
||||||
|
areaSqmMax: 550,
|
||||||
|
companyName: 'Fläche frei per April 2027',
|
||||||
|
isAnonymized: false,
|
||||||
|
quelle: 'SONSTIGES',
|
||||||
|
note: 'Repräsentatives Büro, Ausbaustandard A, direkte SBB-Anbindung.',
|
||||||
|
visibility: 'PLATTFORM',
|
||||||
|
status: 'OFFEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mh-007',
|
||||||
|
createdAt: '2026-05-15T09:00:00Z',
|
||||||
|
createdBy: 'Sarah Keller',
|
||||||
|
verwaltungId: 'v-001',
|
||||||
|
verwaltungName: 'Wincasa AG',
|
||||||
|
direction: 'SUCHE',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
locationHint: 'Luzern',
|
||||||
|
areaSqmMin: 180,
|
||||||
|
areaSqmMax: 320,
|
||||||
|
companyName: 'Startup Hub Luzern',
|
||||||
|
isAnonymized: false,
|
||||||
|
quelle: 'NETZWERKEVENT',
|
||||||
|
note: 'Flexibler Ausbau gewünscht, Co-Working-tauglich.',
|
||||||
|
visibility: 'INTERN',
|
||||||
|
status: 'IN_BEARBEITUNG',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1759,4 +1759,321 @@ export const mockMatches: Match[] = [
|
|||||||
createdAt: '2026-05-20T08:20:00Z',
|
createdAt: '2026-05-20T08:20:00Z',
|
||||||
updatedAt: '2026-05-20T08:20:00Z',
|
updatedAt: '2026-05-20T08:20:00Z',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// DIRECT LISTING MATCHES (prop-d01 … prop-d06)
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-065',
|
||||||
|
propertyId: 'prop-d01',
|
||||||
|
needId: 'need-005',
|
||||||
|
matchScore: 83,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 88, softFactorScore: 80, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 83 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '320m² liegt im Zielkorridor (300–700m²)' },
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 90, contribution: 19.8, explanation: 'Zürich Altstetten — bevorzugte Region Zürich' },
|
||||||
|
{ criterion: 'Budget', weight: 0.17, score: 88, contribution: 14.9, explanation: 'CHF 380/m²/J unter Maximum CHF 576/m²/J' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'ÖV', weight: 0.10, score: 65, contribution: 6.5, explanation: '8 Minuten zur S-Bahn — leicht über Wunsch-Maximum (10 Min.)' },
|
||||||
|
],
|
||||||
|
tradeoffs: [],
|
||||||
|
explainabilitySummary: 'Sehr guter Match für TechStart GmbH: Fläche, Standort Zürich und Budget passen. Minimaler ÖV-Abzug.',
|
||||||
|
confidenceLevel: 0.80,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-15T09:00:00Z',
|
||||||
|
updatedAt: '2026-04-15T09:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-066',
|
||||||
|
propertyId: 'prop-d01',
|
||||||
|
needId: 'need-001',
|
||||||
|
matchScore: 64,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 64 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.18, score: 88, contribution: 15.8, explanation: 'Zürich Altstetten trifft bevorzugte Region' },
|
||||||
|
{ criterion: 'Budget', weight: 0.14, score: 90, contribution: 12.6, explanation: 'CHF 380/m²/J unter Maximum CHF 540/m²/J' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.18, score: 40, contribution: 7.2, explanation: '320m² unterschreitet Minimum von 600m² erheblich' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Fläche', concern: '320m² — halb so gross wie Mindestbedarf (600m²)', severity: 'HIGH', mitigation: 'Evtl. Erweiterungsoptionen im Gebäude prüfen' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Standort und Budget passen für Innovatech AG, aber Fläche deutlich zu klein. Nur als Notfall-Option.',
|
||||||
|
confidenceLevel: 0.80,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Fläche unter Minimum'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-15T09:05:00Z',
|
||||||
|
updatedAt: '2026-04-15T09:05:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-067',
|
||||||
|
propertyId: 'prop-d02',
|
||||||
|
needId: 'need-002',
|
||||||
|
matchScore: 66,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 70, softFactorScore: 78, confidenceModifier: 0.85, dataQualityModifier: 0.82, totalScore: 66 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Pratteln — explizit bevorzugte Region für Schweizer Logistik GmbH' },
|
||||||
|
{ criterion: 'Budget', weight: 0.18, score: 95, contribution: 17.1, explanation: 'CHF 145/m²/J weit unter Maximum CHF 216/m²/J' },
|
||||||
|
{ criterion: 'Hallenhöhe', weight: 0.10, score: 90, contribution: 9.0, explanation: '6.5m Hallenhöhe — Mindestanforderung 6m erfüllt' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.23, score: 22, contribution: 5.1, explanation: '580m² vs. Minimum 1500m² — erhebliche Unterdeckung' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Fläche', concern: 'Nur 39% des Mindestbedarfs gedeckt', severity: 'HIGH', mitigation: 'Kombinierbar mit benachbarten Einheiten falls verfügbar' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Idealer Standort und Preis für Schweizer Logistik GmbH — Fläche reicht aber nicht aus.',
|
||||||
|
confidenceLevel: 0.85,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Fläche zu gering'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-20T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-20T10:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-068',
|
||||||
|
propertyId: 'prop-d03',
|
||||||
|
needId: 'need-005',
|
||||||
|
matchScore: 61,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 65, softFactorScore: 70, confidenceModifier: 0.78, dataQualityModifier: 0.74, totalScore: 61 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 295/m²/J deutlich unter Maximum CHF 576/m²/J' },
|
||||||
|
{ criterion: 'Ausbau', weight: 0.08, score: 85, contribution: 6.8, explanation: 'Vollausbau trifft "Moderner Ausbau"-Anforderung' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 30, contribution: 6.6, explanation: 'Bern liegt ausserhalb bevorzugter Regionen Zürich/Zug' },
|
||||||
|
{ criterion: 'Fläche', weight: 0.17, score: 62, contribution: 10.5, explanation: '240m² leicht unter Minimum 300m²' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Standort', concern: 'Bern liegt nicht in Zürich/Zug — TechStart GmbH hat keine Niederlassung in Bern', severity: 'MEDIUM', mitigation: 'Falls Remote-Team Bern ausgebaut wird, attraktive Option' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Gutes Budget-Match für TechStart, aber Standort Bern ist nicht ihre bevorzugte Region.',
|
||||||
|
confidenceLevel: 0.78,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Standort ausserhalb Präferenz'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-22T11:00:00Z',
|
||||||
|
updatedAt: '2026-04-22T11:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-069',
|
||||||
|
propertyId: 'prop-d04',
|
||||||
|
needId: 'need-004',
|
||||||
|
matchScore: 62,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.72, dataQualityModifier: 0.68, totalScore: 62 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Budget', weight: 0.12, score: 100, contribution: 12.0, explanation: 'CHF 360/m²/J weit unter Maximum CHF 1200/m²/J' },
|
||||||
|
{ criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' },
|
||||||
|
{ criterion: 'Erdgeschoss', weight: 0.15, score: 100, contribution: 15.0, explanation: 'EG-Lage wie gefordert' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.20, score: 32, contribution: 6.4, explanation: 'Winterthur vs. Zürich Innenstadt — andere Stadt' },
|
||||||
|
{ criterion: 'Fläche', weight: 0.10, score: 55, contribution: 5.5, explanation: '185m² leicht unter Minimum 200m²' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Standort', concern: 'Winterthur Stadtzentrum ≠ Zürich Innenstadt', severity: 'MEDIUM', mitigation: 'Winterthur hat hohe Fussgängerfrequenz, günstigere Miete' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Gute Retail-Attribute, aber Winterthur ist nicht das gesuchte Zürich-Innenstadt-Format.',
|
||||||
|
confidenceLevel: 0.72,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Standort differiert'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-02-15T14:00:00Z',
|
||||||
|
updatedAt: '2026-02-15T14:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-070',
|
||||||
|
propertyId: 'prop-d05',
|
||||||
|
needId: 'need-002',
|
||||||
|
matchScore: 81,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 85, softFactorScore: 82, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 81 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Muttenz — explizit bevorzugte Region für Schweizer Logistik GmbH' },
|
||||||
|
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 130/m²/J deutlich unter Maximum CHF 216/m²/J' },
|
||||||
|
{ criterion: 'Hallenhöhe', weight: 0.10, score: 100, contribution: 10.0, explanation: '9m — deutlich über Mindestanforderung 6m' },
|
||||||
|
{ criterion: 'Ladelucken', weight: 0.08, score: 100, contribution: 8.0, explanation: '4 Ladelucken vorhanden — Anforderung erfüllt' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.23, score: 65, contribution: 14.9, explanation: '1200m² leicht unter Minimum 1500m² (-20%)' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Fläche', concern: '1200m² vs. Minimum 1500m² — 20% Unterdeckung', severity: 'LOW', mitigation: 'Erweiterungspotenzial auf Nachbargrundstück vorhanden' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Starker Logistik-Match: Muttenz, Hallenhöhe, Budget und Ladelucken passen. Fläche leicht unter Minimum.',
|
||||||
|
confidenceLevel: 0.88,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-01T08:00:00Z',
|
||||||
|
updatedAt: '2026-05-01T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-071',
|
||||||
|
propertyId: 'prop-d06',
|
||||||
|
needId: 'need-005',
|
||||||
|
matchScore: 70,
|
||||||
|
matchStrength: MatchStrength.MODERATE,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 75, softFactorScore: 72, confidenceModifier: 0.70, dataQualityModifier: 0.65, totalScore: 70 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '450m² liegt im Zielkorridor (300–700m²)' },
|
||||||
|
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 260/m²/J deutlich unter Maximum CHF 576/m²/J' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 42, contribution: 9.2, explanation: 'Luzern liegt ausserhalb bevorzugter Regionen Zürich/Zug/Baar' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Standort', concern: 'Luzern ist 45 Min. von Zürich entfernt', severity: 'MEDIUM', mitigation: 'Attraktiver Preis und S-Bahn-Direktverbindung könnten Kompromiss sein' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Fläche und Budget passen sehr gut für TechStart GmbH. Luzern ist nicht ihre Wunschregion, aber preislich attraktiv.',
|
||||||
|
confidenceLevel: 0.70,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
uncertaintyIndicators: ['Standort ausserhalb Präferenz', 'Inserat inaktiv'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-01-15T09:00:00Z',
|
||||||
|
updatedAt: '2026-01-15T09:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// Direct listings — Strong matches (>80%) ensuring all 6 have ≥1 STRONG
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-072',
|
||||||
|
propertyId: 'prop-d02',
|
||||||
|
needId: 'need-012',
|
||||||
|
matchScore: 85,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 88, softFactorScore: 84, confidenceModifier: 0.90, dataQualityModifier: 0.87, totalScore: 85 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.20, score: 98, contribution: 19.6, explanation: 'Pratteln — explizit bevorzugte Region für Gewerbe Solutions AG' },
|
||||||
|
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 145/m²/J deutlich unter Maximum CHF 168/m²/J' },
|
||||||
|
{ criterion: 'Fläche', weight: 0.22, score: 90, contribution: 19.8, explanation: '580m² liegt ideal im Zielkorridor (400–750m²)' },
|
||||||
|
{ criterion: 'Lkw-Zufahrt', weight: 0.10, score: 100, contribution: 10.0, explanation: 'Lkw-Zufahrt vorhanden — Pflichtkriterium erfüllt' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Hallenhöhe', weight: 0.08, score: 70, contribution: 5.6, explanation: 'Angabe Hallenhöhe nicht vollständig dokumentiert' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Hallenhöhe', concern: 'Angabe Hallenhöhe unvollständig — Besichtigung nötig', severity: 'LOW', mitigation: 'Vor Mietabschluss Vor-Ort-Prüfung empfohlen' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Ausgezeichneter Standort und Preis-Leistungs-Verhältnis für Gewerbe Solutions AG. Pratteln ist die Wunschregion, Fläche und Budget passen sehr gut.',
|
||||||
|
confidenceLevel: 0.90,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-10T08:00:00Z',
|
||||||
|
updatedAt: '2026-05-10T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-073',
|
||||||
|
propertyId: 'prop-d03',
|
||||||
|
needId: 'need-013',
|
||||||
|
matchScore: 84,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 87, softFactorScore: 83, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 84 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.25, score: 96, contribution: 24.0, explanation: 'Bern Bollwerk — perfekter Fit mit Bern Innenstadt / Bahnhof-Nähe' },
|
||||||
|
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 295/m²/J unter Maximum CHF 360/m²/J' },
|
||||||
|
{ criterion: 'Fläche', weight: 0.18, score: 92, contribution: 16.6, explanation: '240m² liegt gut im Korridor (180–320m²)' },
|
||||||
|
{ criterion: 'ÖV', weight: 0.09, score: 100, contribution: 9.0, explanation: 'Hauptbahnhof Bern < 5 Min zu Fuss' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Konferenzraum', weight: 0.06, score: 60, contribution: 3.6, explanation: 'Konferenzraumsituation nicht explizit dokumentiert' },
|
||||||
|
],
|
||||||
|
tradeoffs: [],
|
||||||
|
explainabilitySummary: 'Starke Übereinstimmung: Bern Bollwerk trifft genau die Anforderungen von Bern Consulting AG — Lage, Preis und Fläche passen.',
|
||||||
|
confidenceLevel: 0.88,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: [],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-10T09:00:00Z',
|
||||||
|
updatedAt: '2026-05-10T09:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-074',
|
||||||
|
propertyId: 'prop-d04',
|
||||||
|
needId: 'need-014',
|
||||||
|
matchScore: 82,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 86, softFactorScore: 80, confidenceModifier: 0.84, dataQualityModifier: 0.82, totalScore: 82 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 97, contribution: 21.3, explanation: 'Winterthur Stadthausstrasse — Wunschlage für Winterthur Handel GmbH' },
|
||||||
|
{ criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' },
|
||||||
|
{ criterion: 'Erdgeschoss', weight: 0.12, score: 100, contribution: 12.0, explanation: 'EG-Lage wie gefordert' },
|
||||||
|
{ criterion: 'Budget', weight: 0.14, score: 100, contribution: 14.0, explanation: 'CHF 360/m²/J deutlich unter Maximum CHF 480/m²/J' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Fläche', weight: 0.12, score: 68, contribution: 8.2, explanation: '185m² leicht unter Minimum 150m², aber am unteren Rand' },
|
||||||
|
{ criterion: 'Inaktiv', weight: 0.04, score: 0, contribution: 0, explanation: 'Inserat derzeit inaktiv — Verfügbarkeit zu klären' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Status', concern: 'Inserat inaktiv — Verfügbarkeit muss bestätigt werden', severity: 'LOW', mitigation: 'Direkter Kontakt mit Eigentümer empfohlen' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Winterthur Handel GmbH findet hier genau die gesuchte Retailfläche am richtigen Ort. Status zu klären.',
|
||||||
|
confidenceLevel: 0.84,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: ['Inserat inaktiv'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-10T10:00:00Z',
|
||||||
|
updatedAt: '2026-05-10T10:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'match-075',
|
||||||
|
propertyId: 'prop-d06',
|
||||||
|
needId: 'need-015',
|
||||||
|
matchScore: 83,
|
||||||
|
matchStrength: MatchStrength.STRONG,
|
||||||
|
status: MatchStatus.PENDING_REVIEW,
|
||||||
|
scoreBreakdown: { hardMatchScore: 86, softFactorScore: 82, confidenceModifier: 0.86, dataQualityModifier: 0.84, totalScore: 83 },
|
||||||
|
positiveFactors: [
|
||||||
|
{ criterion: 'Standort', weight: 0.22, score: 95, contribution: 20.9, explanation: 'Luzern Zürichstrasse — Wunschlage für Luzern Advisory GmbH' },
|
||||||
|
{ criterion: 'Fläche', weight: 0.19, score: 96, contribution: 18.2, explanation: '450m² liegt ideal im Korridor (350–600m²)' },
|
||||||
|
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 260/m²/J klar unter Maximum CHF 312/m²/J' },
|
||||||
|
{ criterion: 'ÖV', weight: 0.08, score: 88, contribution: 7.0, explanation: 'Gute S-Bahn-Anbindung Luzern Zentrum' },
|
||||||
|
],
|
||||||
|
negativeFactors: [
|
||||||
|
{ criterion: 'Inaktiv', weight: 0.04, score: 0, contribution: 0, explanation: 'Inserat derzeit inaktiv — Bezugsdatum offen' },
|
||||||
|
],
|
||||||
|
tradeoffs: [
|
||||||
|
{ criterion: 'Status', concern: 'Inserat inaktiv — Aktivierung und Verfügbarkeit zu klären', severity: 'LOW', mitigation: 'Direkte Anfrage an Eigentümer empfohlen' },
|
||||||
|
],
|
||||||
|
explainabilitySummary: 'Sehr guter Match für Luzern Advisory GmbH: Lage, Fläche und Budget passen ausgezeichnet. Inserat-Status zu klären.',
|
||||||
|
confidenceLevel: 0.86,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
uncertaintyIndicators: ['Inserat inaktiv'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-10T11:00:00Z',
|
||||||
|
updatedAt: '2026-05-10T11:00:00Z',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -437,6 +437,142 @@ export const mockNeeds: Need[] = [
|
|||||||
updatedAt: '2025-05-22T10:00:00Z',
|
updatedAt: '2025-05-22T10:00:00Z',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
|
||||||
|
{
|
||||||
|
id: 'need-012',
|
||||||
|
companyName: 'Gewerbe Solutions AG',
|
||||||
|
contactName: 'Andreas Weber',
|
||||||
|
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||||
|
requiredArea: { min: 400, max: 750 },
|
||||||
|
preferredLocations: ['Pratteln', 'Muttenz', 'Schweizerhalle', 'Liestal'],
|
||||||
|
excludedLocations: [],
|
||||||
|
budgetRange: { maxPerSqm: 168, currency: 'CHF' },
|
||||||
|
timing: {
|
||||||
|
earliestMoveIn: '2026-04-01',
|
||||||
|
latestMoveIn: '2026-10-01',
|
||||||
|
contractDurationMonths: 48,
|
||||||
|
flexibleTiming: true,
|
||||||
|
},
|
||||||
|
mustCriteriaText: ['Lkw-Zufahrt', 'Hallenhöhe min 5m', 'Drehstrom vorhanden'],
|
||||||
|
softFactors: {
|
||||||
|
requireParking: true,
|
||||||
|
},
|
||||||
|
weightingProfile: {
|
||||||
|
area: 0.22, location: 0.20, budget: 0.18, timing: 0.12,
|
||||||
|
prestige: 0.02, accessibility: 0.10, expansionPotential: 0.06, flexibility: 0.03,
|
||||||
|
visibility: 0.01, footfall: 0.00, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.01,
|
||||||
|
},
|
||||||
|
confidenceInCriteria: 0.90,
|
||||||
|
extractedFromText: 'Gewerbe-/Lagerfläche 450–700m² in Pratteln oder Muttenz, Lkw-Zufahrt, max. CHF 168/m²/J.',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-03-01T09:00:00Z',
|
||||||
|
updatedAt: '2026-03-01T09:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- need-013: Bern Consulting AG — OFFICE Bern ---
|
||||||
|
{
|
||||||
|
id: 'need-013',
|
||||||
|
companyName: 'Bern Consulting AG',
|
||||||
|
contactName: 'Sabine Gerber',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
requiredArea: { min: 180, max: 320 },
|
||||||
|
preferredLocations: ['Bern', 'Bern Bollwerk', 'Bern Bahnhof', 'Bern Innenstadt'],
|
||||||
|
excludedLocations: [],
|
||||||
|
budgetRange: { maxPerSqm: 360, maxMonthlyTotal: 8500, currency: 'CHF' },
|
||||||
|
timing: {
|
||||||
|
earliestMoveIn: '2026-05-01',
|
||||||
|
latestMoveIn: '2026-11-01',
|
||||||
|
contractDurationMonths: 48,
|
||||||
|
flexibleTiming: true,
|
||||||
|
},
|
||||||
|
mustCriteriaText: ['ÖV-Anbindung < 5 Min', 'Repräsentative Lage', 'Konferenzraum vorhanden'],
|
||||||
|
softFactors: {
|
||||||
|
minPrestige: 65,
|
||||||
|
minAccessibility: 80,
|
||||||
|
requireParking: false,
|
||||||
|
maxPublicTransportMinutes: 5,
|
||||||
|
},
|
||||||
|
weightingProfile: {
|
||||||
|
area: 0.18, location: 0.25, budget: 0.18, timing: 0.11,
|
||||||
|
prestige: 0.08, accessibility: 0.09, expansionPotential: 0.02, flexibility: 0.03,
|
||||||
|
visibility: 0.02, footfall: 0.01, talentAccess: 0.05, esg: 0.02, taxEnvironment: 0.00,
|
||||||
|
},
|
||||||
|
confidenceInCriteria: 0.88,
|
||||||
|
extractedFromText: 'Beratungsunternehmen sucht Bürofläche 200–300m² in Bern Zentrum nahe Bahnhof, max. CHF 360/m²/J.',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-05T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-05T10:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
|
||||||
|
{
|
||||||
|
id: 'need-014',
|
||||||
|
companyName: 'Winterthur Handel GmbH',
|
||||||
|
contactName: 'Pascal Brunner',
|
||||||
|
assetType: AssetType.RETAIL,
|
||||||
|
requiredArea: { min: 150, max: 250 },
|
||||||
|
preferredLocations: ['Winterthur', 'Winterthur Zentrum', 'Winterthur Stadthausstrasse', 'Winterthur Altstadt'],
|
||||||
|
excludedLocations: [],
|
||||||
|
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 8000, currency: 'CHF' },
|
||||||
|
timing: {
|
||||||
|
earliestMoveIn: '2026-06-01',
|
||||||
|
latestMoveIn: '2026-12-01',
|
||||||
|
contractDurationMonths: 36,
|
||||||
|
flexibleTiming: false,
|
||||||
|
},
|
||||||
|
mustCriteriaText: ['Schaufensterfront', 'Erdgeschoss', 'Gute Fussgängerfrequenz'],
|
||||||
|
softFactors: {
|
||||||
|
minPrestige: 70,
|
||||||
|
requireParking: false,
|
||||||
|
maxPublicTransportMinutes: 8,
|
||||||
|
},
|
||||||
|
weightingProfile: {
|
||||||
|
area: 0.12, location: 0.22, budget: 0.14, timing: 0.10,
|
||||||
|
prestige: 0.06, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.03,
|
||||||
|
visibility: 0.12, footfall: 0.12, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
|
||||||
|
},
|
||||||
|
confidenceInCriteria: 0.87,
|
||||||
|
extractedFromText: 'Handelsbetrieb sucht Verkaufsfläche 160–230m² in Winterthur Zentrum, Schaufenster, max. CHF 480/m²/J.',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-03-20T11:00:00Z',
|
||||||
|
updatedAt: '2026-03-20T11:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
|
||||||
|
{
|
||||||
|
id: 'need-015',
|
||||||
|
companyName: 'Luzern Advisory GmbH',
|
||||||
|
contactName: 'Markus Bucher',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
requiredArea: { min: 350, max: 600 },
|
||||||
|
preferredLocations: ['Luzern', 'Luzern Zentrum', 'Luzern-West', 'Emmen'],
|
||||||
|
excludedLocations: [],
|
||||||
|
budgetRange: { maxPerSqm: 312, maxMonthlyTotal: 14000, currency: 'CHF' },
|
||||||
|
timing: {
|
||||||
|
earliestMoveIn: '2026-06-01',
|
||||||
|
latestMoveIn: '2027-01-01',
|
||||||
|
contractDurationMonths: 60,
|
||||||
|
flexibleTiming: true,
|
||||||
|
},
|
||||||
|
mustCriteriaText: ['ÖV < 10 Min', 'Moderner Ausbau', 'Parkplätze vorhanden'],
|
||||||
|
softFactors: {
|
||||||
|
minPrestige: 60,
|
||||||
|
minAccessibility: 70,
|
||||||
|
requireParking: true,
|
||||||
|
maxPublicTransportMinutes: 10,
|
||||||
|
},
|
||||||
|
weightingProfile: {
|
||||||
|
area: 0.19, location: 0.22, budget: 0.18, timing: 0.11,
|
||||||
|
prestige: 0.07, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
|
||||||
|
visibility: 0.02, footfall: 0.01, talentAccess: 0.04, esg: 0.02, taxEnvironment: 0.01,
|
||||||
|
},
|
||||||
|
confidenceInCriteria: 0.86,
|
||||||
|
extractedFromText: 'Beratungsgesellschaft sucht moderne Bürofläche 400–550m² in Luzern, max. CHF 312/m²/J, Parkplätze nötig.',
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-10T14:00:00Z',
|
||||||
|
updatedAt: '2026-04-10T14:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
|
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
|
||||||
{
|
{
|
||||||
id: 'need-uc-c',
|
id: 'need-uc-c',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
location: 'Zürich-Oerlikon',
|
location: 'Zürich-Oerlikon',
|
||||||
matchScore: 87,
|
matchScore: 87,
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
stage: 'QUALIFIED',
|
stage: 'CONTACTED',
|
||||||
areaLabel: '1200 m²',
|
areaLabel: '1200 m²',
|
||||||
rentLabel: 'CHF 38/m²',
|
rentLabel: 'CHF 38/m²',
|
||||||
availabilityLabel: '2026-10-01',
|
availabilityLabel: '2026-10-01',
|
||||||
@@ -74,7 +74,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
location: 'Zürich-West / Technopark',
|
location: 'Zürich-West / Technopark',
|
||||||
matchScore: 76,
|
matchScore: 76,
|
||||||
resultType: 'FUTURE_AVAILABILITY',
|
resultType: 'FUTURE_AVAILABILITY',
|
||||||
stage: 'QUALIFIED',
|
stage: 'CONTACTED',
|
||||||
areaLabel: '~600 m²',
|
areaLabel: '~600 m²',
|
||||||
availabilityLabel: '~10 Monate',
|
availabilityLabel: '~10 Monate',
|
||||||
addedAt: '2026-05-06T10:00:00Z',
|
addedAt: '2026-05-06T10:00:00Z',
|
||||||
@@ -121,7 +121,7 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
title: 'Gewerbe Güterstrasse Basel',
|
title: 'Gewerbe Güterstrasse Basel',
|
||||||
location: 'Basel',
|
location: 'Basel',
|
||||||
matchScore: 68,
|
matchScore: 68,
|
||||||
resultType: 'EXTERNAL_MARKET',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
stage: 'CLOSED_LOST',
|
stage: 'CLOSED_LOST',
|
||||||
areaLabel: '800 m²',
|
areaLabel: '800 m²',
|
||||||
rentLabel: 'CHF 28/m²',
|
rentLabel: 'CHF 28/m²',
|
||||||
|
|||||||
+225
-29
@@ -1,5 +1,6 @@
|
|||||||
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
import { AssetType, ResultType, AvailabilityStatus, DataFreshness, RiskLevel } from '../domain/enums'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
|
import { resolvePropertyImage, getPoolKey } from '../lib/propertyImageResolver'
|
||||||
|
|
||||||
export const mockProperties: Property[] = [
|
export const mockProperties: Property[] = [
|
||||||
|
|
||||||
@@ -607,7 +608,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 56,
|
prestige: 56,
|
||||||
accessibility: 88,
|
accessibility: 88,
|
||||||
visibilityScore: 72,
|
visibilityScore: 72,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 65,
|
talentAccess: 65,
|
||||||
parkingSpots: 1,
|
parkingSpots: 1,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -829,7 +830,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 60,
|
prestige: 60,
|
||||||
accessibility: 88,
|
accessibility: 88,
|
||||||
visibilityScore: 75,
|
visibilityScore: 75,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 70,
|
talentAccess: 70,
|
||||||
parkingSpots: 2,
|
parkingSpots: 2,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -1071,14 +1072,14 @@ export const mockProperties: Property[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// EXTERNAL_MARKET (10)
|
// VERIFIED_PORTFOLIO — formerly external market (migrated)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'prop-003',
|
id: 'prop-003',
|
||||||
title: 'Retail-Fläche Bahnhofstrasse 88',
|
title: 'Retail-Fläche Bahnhofstrasse 88',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9483, lng: 7.4474 } },
|
||||||
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
|
address: { street: 'Bahnhofstrasse', houseNumber: '88', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 320,
|
areaSqm: 320,
|
||||||
@@ -1122,7 +1123,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-004',
|
id: 'prop-004',
|
||||||
title: 'Gemischte Gewerbeeinheit Europaallee',
|
title: 'Gemischte Gewerbeeinheit Europaallee',
|
||||||
assetType: AssetType.MIXED,
|
assetType: AssetType.MIXED,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
|
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3775, lng: 8.5398 } },
|
||||||
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
address: { street: 'Europaallee', houseNumber: '21', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 1150,
|
areaSqm: 1150,
|
||||||
@@ -1246,7 +1247,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-017',
|
id: 'prop-017',
|
||||||
title: 'Ladenfläche Löwenstrasse 28',
|
title: 'Ladenfläche Löwenstrasse 28',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } },
|
location: { city: 'Zürich', district: 'Innenstadt', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3766, lng: 8.5385 } },
|
||||||
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
address: { street: 'Löwenstrasse', houseNumber: '28', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 350,
|
areaSqm: 350,
|
||||||
@@ -1290,7 +1291,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-018',
|
id: 'prop-018',
|
||||||
title: 'Bürofläche Breitenrain 14',
|
title: 'Bürofläche Breitenrain 14',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } },
|
location: { city: 'Bern', district: 'Breitenrain', canton: 'BE', country: 'CH', coordinates: { lat: 46.9598, lng: 7.4522 } },
|
||||||
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
|
address: { street: 'Breitenrainstrasse', houseNumber: '14', postalCode: '3014', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 780,
|
areaSqm: 780,
|
||||||
@@ -1458,7 +1459,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-022',
|
id: 'prop-022',
|
||||||
title: 'Bürofläche St.Gallen Centrum 7',
|
title: 'Bürofläche St.Gallen Centrum 7',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } },
|
location: { city: 'St. Gallen', district: 'Centrum', canton: 'SG', country: 'CH', coordinates: { lat: 47.4245, lng: 9.3767 } },
|
||||||
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
|
address: { street: 'Marktgasse', houseNumber: '7', postalCode: '9000', city: 'St. Gallen', country: 'CH' },
|
||||||
areaSqm: 700,
|
areaSqm: 700,
|
||||||
@@ -1666,7 +1667,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-031',
|
id: 'prop-031',
|
||||||
title: 'Bürofläche Hardturm West 16',
|
title: 'Bürofläche Hardturm West 16',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } },
|
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3862, lng: 8.5045 } },
|
||||||
address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
address: { street: 'Hardturmstrasse', houseNumber: '16', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 780,
|
areaSqm: 780,
|
||||||
@@ -1780,7 +1781,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-034',
|
id: 'prop-034',
|
||||||
title: 'Ladenfläche Lorrainestrasse 8',
|
title: 'Ladenfläche Lorrainestrasse 8',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } },
|
location: { city: 'Bern', district: 'Lorraine', canton: 'BE', country: 'CH', coordinates: { lat: 46.9565, lng: 7.4388 } },
|
||||||
address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' },
|
address: { street: 'Lorrainestrasse', houseNumber: '8', postalCode: '3013', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 340,
|
areaSqm: 340,
|
||||||
@@ -1844,7 +1845,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-036',
|
id: 'prop-036',
|
||||||
title: 'Lagerhalle Klybeckstrasse 280',
|
title: 'Lagerhalle Klybeckstrasse 280',
|
||||||
assetType: AssetType.LOGISTICS,
|
assetType: AssetType.LOGISTICS,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } },
|
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.5744, lng: 7.5862 } },
|
||||||
address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' },
|
address: { street: 'Klybeckstrasse', houseNumber: '280', postalCode: '4057', city: 'Basel', country: 'CH' },
|
||||||
areaSqm: 2600,
|
areaSqm: 2600,
|
||||||
@@ -2016,7 +2017,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 48,
|
prestige: 48,
|
||||||
accessibility: 78,
|
accessibility: 78,
|
||||||
visibilityScore: 68,
|
visibilityScore: 68,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 60,
|
talentAccess: 60,
|
||||||
parkingSpots: 2,
|
parkingSpots: 2,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -2206,7 +2207,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-055',
|
id: 'prop-055',
|
||||||
title: 'Ladenfläche Weststrasse 44',
|
title: 'Ladenfläche Weststrasse 44',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 3', canton: 'ZH', country: 'CH', coordinates: { lat: 47.372, lng: 8.521 } },
|
location: { city: 'Zürich', district: 'Kreis 3', canton: 'ZH', country: 'CH', coordinates: { lat: 47.372, lng: 8.521 } },
|
||||||
address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' },
|
address: { street: 'Weststrasse', houseNumber: '44', postalCode: '8003', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 150,
|
areaSqm: 150,
|
||||||
@@ -2229,7 +2230,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 58,
|
prestige: 58,
|
||||||
accessibility: 85,
|
accessibility: 85,
|
||||||
visibilityScore: 70,
|
visibilityScore: 70,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 65,
|
talentAccess: 65,
|
||||||
parkingSpots: 2,
|
parkingSpots: 2,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -2254,7 +2255,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-056',
|
id: 'prop-056',
|
||||||
title: 'Gewerbefläche Hohlstrasse 88',
|
title: 'Gewerbefläche Hohlstrasse 88',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.375, lng: 8.527 } },
|
location: { city: 'Zürich', district: 'Kreis 4', canton: 'ZH', country: 'CH', coordinates: { lat: 47.375, lng: 8.527 } },
|
||||||
address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
address: { street: 'Hohlstrasse', houseNumber: '88', postalCode: '8004', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 155,
|
areaSqm: 155,
|
||||||
@@ -2372,7 +2373,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 55,
|
prestige: 55,
|
||||||
accessibility: 82,
|
accessibility: 82,
|
||||||
visibilityScore: 70,
|
visibilityScore: 70,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 65,
|
talentAccess: 65,
|
||||||
parkingSpots: 0,
|
parkingSpots: 0,
|
||||||
publicTransportMinutes: 5,
|
publicTransportMinutes: 5,
|
||||||
@@ -2667,7 +2668,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-064',
|
id: 'prop-064',
|
||||||
title: 'Bürofläche Förrlibuckstrasse 22',
|
title: 'Bürofläche Förrlibuckstrasse 22',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
||||||
address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
address: { street: 'Förrlibuckstrasse', houseNumber: '22', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 185,
|
areaSqm: 185,
|
||||||
@@ -2714,7 +2715,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-065',
|
id: 'prop-065',
|
||||||
title: 'Bürofläche Binzmühlestrasse 95, Oerlikon',
|
title: 'Bürofläche Binzmühlestrasse 95, Oerlikon',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.411, lng: 8.544 } },
|
location: { city: 'Zürich', district: 'Oerlikon', canton: 'ZH', country: 'CH', coordinates: { lat: 47.411, lng: 8.544 } },
|
||||||
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
|
address: { street: 'Binzmühlestrasse', houseNumber: '95', postalCode: '8050', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 175,
|
areaSqm: 175,
|
||||||
@@ -2813,7 +2814,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-067',
|
id: 'prop-067',
|
||||||
title: 'Bürofläche Altstetterstrasse 222',
|
title: 'Bürofläche Altstetterstrasse 222',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
||||||
address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
address: { street: 'Altstetterstrasse', houseNumber: '222', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 190,
|
areaSqm: 190,
|
||||||
@@ -3175,7 +3176,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-074',
|
id: 'prop-074',
|
||||||
title: 'Bürofläche Uraniastrasse 14',
|
title: 'Bürofläche Uraniastrasse 14',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Kreis 1', canton: 'ZH', country: 'CH', coordinates: { lat: 47.376, lng: 8.541 } },
|
location: { city: 'Zürich', district: 'Kreis 1', canton: 'ZH', country: 'CH', coordinates: { lat: 47.376, lng: 8.541 } },
|
||||||
address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
address: { street: 'Uraniastrasse', houseNumber: '14', postalCode: '8001', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 510,
|
areaSqm: 510,
|
||||||
@@ -3223,7 +3224,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-075',
|
id: 'prop-075',
|
||||||
title: 'Bürofläche Seefeldstrasse 30',
|
title: 'Bürofläche Seefeldstrasse 30',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Seefeld', canton: 'ZH', country: 'CH', coordinates: { lat: 47.356, lng: 8.550 } },
|
location: { city: 'Zürich', district: 'Seefeld', canton: 'ZH', country: 'CH', coordinates: { lat: 47.356, lng: 8.550 } },
|
||||||
address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' },
|
address: { street: 'Seefeldstrasse', houseNumber: '30', postalCode: '8008', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 540,
|
areaSqm: 540,
|
||||||
@@ -3452,7 +3453,7 @@ export const mockProperties: Property[] = [
|
|||||||
prestige: 85,
|
prestige: 85,
|
||||||
accessibility: 95,
|
accessibility: 95,
|
||||||
visibilityScore: 88,
|
visibilityScore: 88,
|
||||||
passerbyFrequency: 'MEDIUM_HIGH',
|
passerbyFrequency: 'HIGH',
|
||||||
talentAccess: 76,
|
talentAccess: 76,
|
||||||
parkingSpots: 0,
|
parkingSpots: 0,
|
||||||
publicTransportMinutes: 3,
|
publicTransportMinutes: 3,
|
||||||
@@ -3483,7 +3484,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-080',
|
id: 'prop-080',
|
||||||
title: 'Ladenfläche Spitalgasse 34',
|
title: 'Ladenfläche Spitalgasse 34',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
||||||
address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' },
|
address: { street: 'Spitalgasse', houseNumber: '34', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 280,
|
areaSqm: 280,
|
||||||
@@ -3531,7 +3532,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-081',
|
id: 'prop-081',
|
||||||
title: 'Ladenfläche Münstergasse 12',
|
title: 'Ladenfläche Münstergasse 12',
|
||||||
assetType: AssetType.RETAIL,
|
assetType: AssetType.RETAIL,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.948, lng: 7.447 } },
|
||||||
address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' },
|
address: { street: 'Münstergasse', houseNumber: '12', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 350,
|
areaSqm: 350,
|
||||||
@@ -3838,7 +3839,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-087',
|
id: 'prop-087',
|
||||||
title: 'Lagerhalle Hauptstrasse 180, Pratteln',
|
title: 'Lagerhalle Hauptstrasse 180, Pratteln',
|
||||||
assetType: AssetType.LOGISTICS,
|
assetType: AssetType.LOGISTICS,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.519, lng: 7.689 } },
|
location: { city: 'Pratteln', district: 'Industriezone', canton: 'BL', country: 'CH', coordinates: { lat: 47.519, lng: 7.689 } },
|
||||||
address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' },
|
address: { street: 'Hauptstrasse', houseNumber: '180', postalCode: '4133', city: 'Pratteln', country: 'CH' },
|
||||||
areaSqm: 3000,
|
areaSqm: 3000,
|
||||||
@@ -3885,7 +3886,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-088',
|
id: 'prop-088',
|
||||||
title: 'Lagerhalle Westquaistrasse 12',
|
title: 'Lagerhalle Westquaistrasse 12',
|
||||||
assetType: AssetType.LOGISTICS,
|
assetType: AssetType.LOGISTICS,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.574, lng: 7.591 } },
|
location: { city: 'Basel', district: 'Kleinhüningen', canton: 'BS', country: 'CH', coordinates: { lat: 47.574, lng: 7.591 } },
|
||||||
address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' },
|
address: { street: 'Westquaistrasse', houseNumber: '12', postalCode: '4057', city: 'Basel', country: 'CH' },
|
||||||
areaSqm: 2600,
|
areaSqm: 2600,
|
||||||
@@ -4091,7 +4092,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-092',
|
id: 'prop-092',
|
||||||
title: 'Bürofläche Hohlstrasse 534, Altstetten',
|
title: 'Bürofläche Hohlstrasse 534, Altstetten',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.385, lng: 8.488 } },
|
||||||
address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
address: { street: 'Hohlstrasse', houseNumber: '534', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 900,
|
areaSqm: 900,
|
||||||
@@ -4138,7 +4139,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-093',
|
id: 'prop-093',
|
||||||
title: 'Bürofläche Technopark Zürich-West',
|
title: 'Bürofläche Technopark Zürich-West',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
location: { city: 'Zürich', district: 'Zürich-West', canton: 'ZH', country: 'CH', coordinates: { lat: 47.388, lng: 8.510 } },
|
||||||
address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
address: { street: 'Technoparkstrasse', houseNumber: '2', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||||
areaSqm: 960,
|
areaSqm: 960,
|
||||||
@@ -4343,7 +4344,7 @@ export const mockProperties: Property[] = [
|
|||||||
id: 'prop-097',
|
id: 'prop-097',
|
||||||
title: 'Bürofläche Baarerstrasse 18, Zug',
|
title: 'Bürofläche Baarerstrasse 18, Zug',
|
||||||
assetType: AssetType.OFFICE,
|
assetType: AssetType.OFFICE,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
location: { city: 'Zug', district: 'Innenstadt', canton: 'ZG', country: 'CH', coordinates: { lat: 47.174, lng: 8.514 } },
|
location: { city: 'Zug', district: 'Innenstadt', canton: 'ZG', country: 'CH', coordinates: { lat: 47.174, lng: 8.514 } },
|
||||||
address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' },
|
address: { street: 'Baarerstrasse', houseNumber: '18', postalCode: '6300', city: 'Zug', country: 'CH' },
|
||||||
areaSqm: 320,
|
areaSqm: 320,
|
||||||
@@ -4486,4 +4487,199 @@ export const mockProperties: Property[] = [
|
|||||||
updatedAt: '2025-05-22T10:00:00Z',
|
updatedAt: '2025-05-22T10:00:00Z',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// DIRECT LISTINGS (6) — created via Inserate-Formular, sourceType DIRECT
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'prop-d01',
|
||||||
|
title: 'Bürofläche Bachmattstrasse 28',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3906, lng: 8.4886 } },
|
||||||
|
address: { street: 'Bachmattstrasse', houseNumber: '28', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||||
|
areaSqm: 320,
|
||||||
|
rentPricePerSqm: 380,
|
||||||
|
availabilityDate: '2026-06-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.80,
|
||||||
|
dataQuality: { score: 0.78, missingCriticalFields: [], missingOptionalFields: ['softFactors', 'hardFacts'], lastVerifiedAt: '2026-03-10', freshness: DataFreshness.FRESH, warnings: [] },
|
||||||
|
softFactors: { prestige: 60, accessibility: 75, publicTransportMinutes: 8, parkingSpots: 4 },
|
||||||
|
hardFacts: { floor: 3, fitOut: 'BASIC', parking: 4 },
|
||||||
|
floorLevel: 3,
|
||||||
|
ancillaryCosts: 4.2,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
description: 'Helle Bürofläche im 3. OG an der Bachmattstrasse, Zürich-Altstetten. 320 m², Basisausbau, Parkplätze im Haus. 8 Minuten zur S-Bahn Altstetten.',
|
||||||
|
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-02-14T09:30:00Z',
|
||||||
|
updatedAt: '2026-02-14T09:30:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'prop-d02',
|
||||||
|
title: 'Gewerbe- & Lagerfläche Pratteln',
|
||||||
|
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: { city: 'Pratteln', district: 'Industriezone Nord', canton: 'BL', country: 'CH', coordinates: { lat: 47.5199, lng: 7.6951 } },
|
||||||
|
address: { street: 'Industriestrasse', houseNumber: '14', postalCode: '4133', city: 'Pratteln', country: 'CH' },
|
||||||
|
areaSqm: 580,
|
||||||
|
rentPricePerSqm: 145,
|
||||||
|
availabilityDate: '2026-05-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.85,
|
||||||
|
dataQuality: { score: 0.82, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-04-01', freshness: DataFreshness.FRESH, warnings: [] },
|
||||||
|
softFactors: { prestige: 40, accessibility: 80, parkingSpots: 10 },
|
||||||
|
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 10, ceilingHeightM: 6.5, loadingDocksCount: 2 },
|
||||||
|
floorLevel: 0,
|
||||||
|
ancillaryCosts: 2.8,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
description: 'Gewerbe- und Lagerhalle im Industriegebiet Pratteln. Erdgeschoss, 580 m², 6,5 m Hallenhöhe, 2 Ladelucken, 10 Aussenparkplätze. Sofort verfügbar.',
|
||||||
|
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-03-05T11:00:00Z',
|
||||||
|
updatedAt: '2026-03-05T11:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'prop-d03',
|
||||||
|
title: 'Bürofläche Bollwerk 15, Bern',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9497, lng: 7.4391 } },
|
||||||
|
address: { street: 'Bollwerk', houseNumber: '15', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||||
|
areaSqm: 240,
|
||||||
|
rentPricePerSqm: 295,
|
||||||
|
availabilityDate: '2026-07-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.78,
|
||||||
|
dataQuality: { score: 0.74, missingCriticalFields: [], missingOptionalFields: ['hardFacts'], lastVerifiedAt: '2026-04-20', freshness: DataFreshness.FRESH, warnings: [] },
|
||||||
|
softFactors: { prestige: 72, accessibility: 92, publicTransportMinutes: 3, parkingSpots: 0 },
|
||||||
|
hardFacts: { floor: 4, fitOut: 'FULL' },
|
||||||
|
floorLevel: 4,
|
||||||
|
ancillaryCosts: 3.5,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
description: 'Repräsentative Bürofläche im 4. OG am Bollwerk, Bern. 240 m², Vollausbau, Toplage direkt beim Bahnhof Bern (3 Min.). Kein Parkplatz.',
|
||||||
|
images: ['https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&h=400&fit=crop'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-02T14:15:00Z',
|
||||||
|
updatedAt: '2026-04-02T14:15:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'prop-d04',
|
||||||
|
title: 'Verkaufsfläche Stadthausstrasse Winterthur',
|
||||||
|
assetType: AssetType.RETAIL,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
status: 'INACTIVE',
|
||||||
|
location: { city: 'Winterthur', district: 'Zentrum', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4997, lng: 8.7241 } },
|
||||||
|
address: { street: 'Stadthausstrasse', houseNumber: '6', postalCode: '8400', city: 'Winterthur', country: 'CH' },
|
||||||
|
areaSqm: 185,
|
||||||
|
rentPricePerSqm: 360,
|
||||||
|
availabilityDate: '2026-08-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.72,
|
||||||
|
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] },
|
||||||
|
softFactors: { prestige: 68, accessibility: 85, visibilityScore: 80, publicTransportMinutes: 5 },
|
||||||
|
hardFacts: { floor: 0, fitOut: 'BASIC', hasStorefront: true },
|
||||||
|
floorLevel: 0,
|
||||||
|
ancillaryCosts: 5.0,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
description: 'Ladenfläche an der Stadthausstrasse Winterthur, Erdgeschoss, Schaufensterfront. 185 m², Basisausbau. Zentrale Lage, hohe Passantenfrequenz. Derzeit deaktiviert — Verhandlung sistiert.',
|
||||||
|
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-01-20T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-15T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'prop-d05',
|
||||||
|
title: 'Logistikhalle Muttenz Rheinstrasse',
|
||||||
|
assetType: AssetType.LOGISTICS,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: { city: 'Muttenz', district: 'Frenkendorf Ost', canton: 'BL', country: 'CH', coordinates: { lat: 47.5150, lng: 7.6469 } },
|
||||||
|
address: { street: 'Rheinstrasse', houseNumber: '61', postalCode: '4132', city: 'Muttenz', country: 'CH' },
|
||||||
|
areaSqm: 1200,
|
||||||
|
rentPricePerSqm: 130,
|
||||||
|
availabilityDate: '2026-06-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.88,
|
||||||
|
dataQuality: { score: 0.85, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-05-01', freshness: DataFreshness.FRESH, warnings: [] },
|
||||||
|
softFactors: { prestige: 35, accessibility: 88, parkingSpots: 20 },
|
||||||
|
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 20, ceilingHeightM: 9.0, loadingDocksCount: 4, powerSupplyKva: 400 },
|
||||||
|
floorLevel: 0,
|
||||||
|
ancillaryCosts: 2.2,
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
description: 'Moderne Logistikhalle in Muttenz, 1200 m², 9 m Hallenhöhe, 4 Ladelucken, Stromanschluss 400 kVA. Direkter Autobahnanschluss A2/A3, 20 Aussenparkplätze.',
|
||||||
|
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-28T16:00:00Z',
|
||||||
|
updatedAt: '2026-04-28T16:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'prop-d06',
|
||||||
|
title: 'Bürofläche Zürichstrasse 45, Luzern',
|
||||||
|
assetType: AssetType.OFFICE,
|
||||||
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
|
status: 'INACTIVE',
|
||||||
|
location: { city: 'Luzern', district: 'Littau', canton: 'LU', country: 'CH', coordinates: { lat: 47.0530, lng: 8.2592 } },
|
||||||
|
address: { street: 'Zürichstrasse', houseNumber: '45', postalCode: '6014', city: 'Luzern', country: 'CH' },
|
||||||
|
areaSqm: 450,
|
||||||
|
rentPricePerSqm: 260,
|
||||||
|
availabilityDate: '2026-09-01',
|
||||||
|
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.70,
|
||||||
|
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] },
|
||||||
|
softFactors: { prestige: 55, accessibility: 70, publicTransportMinutes: 12, parkingSpots: 8 },
|
||||||
|
hardFacts: { floor: 1, fitOut: 'BASIC', parking: 8 },
|
||||||
|
floorLevel: 1,
|
||||||
|
ancillaryCosts: 3.8,
|
||||||
|
riskLevel: RiskLevel.MEDIUM,
|
||||||
|
description: 'Bürofläche Zürichstrasse 45, Luzern-Littau. 450 m², 1. OG, Basisausbau. 8 Tiefgaragenplätze. Deaktiviert — Preisüberarbeitung ausstehend.',
|
||||||
|
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2025-12-10T08:00:00Z',
|
||||||
|
updatedAt: '2026-03-20T12:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Assign lease contract URLs to ~50 % of properties (every other one)
|
||||||
|
mockProperties.forEach((p, i) => {
|
||||||
|
if (i % 2 !== 0) return
|
||||||
|
const startYear = p.leaseStartDate ? new Date(p.leaseStartDate).getFullYear() : 2020 + (i % 5)
|
||||||
|
const endYear = p.leaseEndDate ? new Date(p.leaseEndDate).getFullYear() : startYear + 5
|
||||||
|
p.leaseContractUrl = `https://docs.wincasa.ch/mietvertraege/${p.id}.pdf`
|
||||||
|
p.leaseContractName = `Mietvertrag ${startYear}–${endYear}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assign images sequentially per pool so no two properties ever share the same image
|
||||||
|
// (until pool wraps, which happens after ~12 properties per pool type)
|
||||||
|
const _poolCounters: Record<string, number> = {}
|
||||||
|
mockProperties.forEach(p => {
|
||||||
|
const district = (p.location.district ?? '').toLowerCase()
|
||||||
|
const prestige = p.softFactors?.prestige ?? 60
|
||||||
|
const key = getPoolKey(p.assetType, district, prestige)
|
||||||
|
const poolIndex = _poolCounters[key] ?? 0
|
||||||
|
_poolCounters[key] = poolIndex + 1
|
||||||
|
p.images = [resolvePropertyImage({
|
||||||
|
assetType: p.assetType,
|
||||||
|
city: p.location.city,
|
||||||
|
district: p.location.district,
|
||||||
|
prestige: p.softFactors?.prestige,
|
||||||
|
floorLevel: p.floorLevel,
|
||||||
|
propertyId: p.id,
|
||||||
|
poolIndex,
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user