Compare commits
21 Commits
e95490eb72
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6d3071010 | |||
| 5a1f412ce3 | |||
| b2530f9e20 | |||
| a825672197 | |||
| 36570c5bdc | |||
| 98d2770054 | |||
| 6fb2fb027c | |||
| 7f6b7766e6 | |||
| 63909c8caf | |||
| d8ff380d6e | |||
| 81d66d24a9 | |||
| e597e81ff5 | |||
| d2af8664f4 | |||
| cd220ed282 | |||
| a9ee53f185 | |||
| fe8444c1d3 | |||
| 453f2b1982 | |||
| d804d1c923 | |||
| a9e038a64f | |||
| 61a26d00a7 | |||
| f2edc3b2ef |
@@ -3,7 +3,9 @@
|
||||
"allow": [
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(node -e ' *)"
|
||||
"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"}
|
||||
Binary file not shown.
+1
-3
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
|
||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||
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 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 PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||
|
||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -62,7 +61,6 @@ function App() {
|
||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||
|
||||
@@ -123,7 +123,6 @@ export function InquiryReplyComposer({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
bgcolor: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||
borderRadius: 1,
|
||||
|
||||
@@ -1,18 +1,86 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { Box, Divider, Typography } from '@mui/material'
|
||||
import { Building2, FileText, MapPin } from 'lucide-react'
|
||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||
import { mockProperties } from '../../mock-data/properties'
|
||||
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
interface MockPdfPreviewProps {
|
||||
fields: 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 propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||
|
||||
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] ?? ''
|
||||
|
||||
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||
}}
|
||||
>
|
||||
{/* ── Page 1 — Offer letter ── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
||||
<FileText size={16} />
|
||||
<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 }}>
|
||||
{v('closing')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
||||
}
|
||||
|
||||
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 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
||||
<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"
|
||||
value={a.date}
|
||||
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ width: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
||||
progress={progress}
|
||||
ready={ready}
|
||||
draft={draft}
|
||||
property={property}
|
||||
property={property ?? undefined}
|
||||
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||
onAttach={() => {
|
||||
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 addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
|
||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
|
||||
return
|
||||
}
|
||||
setOfferDraftId(res.data.id)
|
||||
setStep('pdf_review')
|
||||
setStep('select_fields')
|
||||
}
|
||||
|
||||
if (!need) {
|
||||
|
||||
@@ -12,22 +12,25 @@ import {
|
||||
import { X } from 'lucide-react'
|
||||
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
||||
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
|
||||
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||
import { OfferCheckedAction } from './OfferCheckedAction'
|
||||
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 }[] = [
|
||||
{ key: 'select_properties', label: 'Objekte wählen' },
|
||||
{ key: 'select_fields', label: 'Felder wählen' },
|
||||
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
||||
{ key: 'send', label: 'Senden' },
|
||||
]
|
||||
|
||||
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
||||
select_properties: 0,
|
||||
pdf_review: 1,
|
||||
checked: 1, // same visual step as pdf_review
|
||||
send: 2,
|
||||
select_fields: 1,
|
||||
pdf_review: 2,
|
||||
checked: 2, // same visual step as pdf_review
|
||||
send: 3,
|
||||
}
|
||||
|
||||
export function OfferWizard() {
|
||||
@@ -48,13 +51,15 @@ export function OfferWizard() {
|
||||
fullScreen={fullScreen}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
height: fullScreen ? '100vh' : '85vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -112,6 +117,7 @@ export function OfferWizard() {
|
||||
{/* Step content */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
||||
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
|
||||
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
||||
{currentStep === 'checked' && <OfferCheckedAction />}
|
||||
{currentStep === 'send' && <OfferChatComposer />}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@mui/material'
|
||||
import { Download, Send, X } from 'lucide-react'
|
||||
import type { Inquiry } from '../../domain/inquiry'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||
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 selectedProperties = draft?.selectedPropertyIds
|
||||
.map(id => allProperties.find(p => p.id === id))
|
||||
.filter((p): p is Property => !!p) ?? []
|
||||
|
||||
const toggleProperty = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
||||
}
|
||||
|
||||
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 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||
<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) {
|
||||
const isSelected = (key: ReportObjectFieldKey) =>
|
||||
value.selectedOptionalFields.includes(key)
|
||||
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
|
||||
|
||||
{FIELD_GROUPS.map(group => {
|
||||
const groupKeys = group.fields.map(f => f.key)
|
||||
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
|
||||
return (
|
||||
<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 } }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
||||
import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, Typography } from '@mui/material'
|
||||
import { Save } from 'lucide-react'
|
||||
import { NeedCardPreview } from './NeedCardPreview'
|
||||
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||
@@ -10,7 +10,9 @@ interface Props {
|
||||
needTitle: string
|
||||
overallConfidence: number
|
||||
isSaving: boolean
|
||||
isAnonymous: boolean
|
||||
onNeedTitleChange: (t: string) => void
|
||||
onAnonymousChange: (v: boolean) => void
|
||||
onBack: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
@@ -22,7 +24,9 @@ export function AISearchSavePreview({
|
||||
needTitle,
|
||||
overallConfidence,
|
||||
isSaving,
|
||||
isAnonymous,
|
||||
onNeedTitleChange,
|
||||
onAnonymousChange,
|
||||
onBack,
|
||||
onSave,
|
||||
}: Props) {
|
||||
@@ -39,7 +43,41 @@ export function AISearchSavePreview({
|
||||
needTitle={needTitle}
|
||||
onNeedTitleChange={onNeedTitleChange}
|
||||
/>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
|
||||
{/* Anonymity option */}
|
||||
<Box sx={{
|
||||
maxWidth: 720, mx: 'auto', mt: 2,
|
||||
p: 1.5, borderRadius: 1.5, border: '1px solid',
|
||||
borderColor: isAnonymous ? '#7c3aed' : '#e2e8f0',
|
||||
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isAnonymous}
|
||||
onChange={e => onAnonymousChange(e.target.checked)}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: isAnonymous ? '#6d28d9' : 'text.primary' }}>
|
||||
Anonymes Suchprofil
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: isAnonymous ? '#7c3aed' : 'text.secondary' }}>
|
||||
Firmenname wird nicht an Vermieter übermittelt — Verwalter sehen nur Branche und Flächenbedarf
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 2 }}>
|
||||
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
|
||||
← Zurück
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
|
||||
@@ -73,7 +73,46 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
|
||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 200"
|
||||
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
||||
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
|
||||
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Divisibility */}
|
||||
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid #f1f5f9' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={c.requiresDivisibility ?? false}
|
||||
onChange={e => set({ ...c, requiresDivisibility: e.target.checked || undefined })}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption" sx={{ fontWeight: 600 }}>Fläche muss teilbar sein</Typography>}
|
||||
sx={{ mb: c.requiresDivisibility ? 1 : 0 }}
|
||||
/>
|
||||
{c.requiresDivisibility && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindesteinheit (m²)</Typography>
|
||||
<TextField
|
||||
size="small" type="number"
|
||||
placeholder="z.B. 150"
|
||||
value={c.minDivisibleUnit ?? ''}
|
||||
onChange={e => set({ ...c, minDivisibleUnit: parseInt(e.target.value) || undefined })}
|
||||
sx={{ width: 160 }}
|
||||
slotProps={{ htmlInput: { min: 10, max: 5000, step: 10 } }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||
import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
|
||||
|
||||
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
|
||||
|
||||
const RADIUS_MARKS = [5, 10, 20, 30, 50, 100].map(v => ({ value: v, label: v === 5 || v === 100 ? `${v} km` : `${v}` }))
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
||||
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
|
||||
{(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) === 0 && <Box sx={{ mb: 1.5 }} />}
|
||||
|
||||
{/* Radius */}
|
||||
<Box sx={{ mb: 2.5, px: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>Suchradius</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
|
||||
{c.searchRadius ?? 30} km
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={c.searchRadius ?? 30}
|
||||
min={1}
|
||||
max={100}
|
||||
marks={RADIUS_MARKS}
|
||||
step={1}
|
||||
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
|
||||
size="small"
|
||||
sx={{ color: '#1e3a5f', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Budget */}
|
||||
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
|
||||
@@ -220,6 +242,30 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Anonymous search */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e2e8f0'}` }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
|
||||
Anonyme Suche
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
Firmenname wird nicht an Vermieter übermittelt
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={c.isAnonymous ?? false}
|
||||
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
|
||||
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
Target,
|
||||
CheckSquare,
|
||||
Search,
|
||||
List,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
MessageSquare,
|
||||
Kanban,
|
||||
BellRing,
|
||||
Plus,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -50,12 +49,10 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||
{ 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/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
||||
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
|
||||
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
|
||||
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
|
||||
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
[WorkspaceType.DEMAND]: {
|
||||
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
{ 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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Divider, Typography } from '@mui/material'
|
||||
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
// ── Future Availability Card ──────────────────────────────────────────────────
|
||||
|
||||
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
const isControlled = vm.signalIsControlled ?? false
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -243,4 +244,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -115,6 +115,35 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
{vm.availabilityLabel && (
|
||||
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||
)}
|
||||
{vm.fitOutLabel && (
|
||||
<Chip
|
||||
label={vm.fitOutLabel}
|
||||
size="small" variant="outlined"
|
||||
sx={{
|
||||
fontSize: 10, height: 20,
|
||||
borderColor: vm.fitOutViable ? '#059669' : '#94a3b8',
|
||||
color: vm.fitOutViable ? '#059669' : '#64748b',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{vm.fitOutInvestment && !vm.fitOutInvestment.isFullyCovered && (
|
||||
<Chip
|
||||
label={vm.fitOutInvestment.shortLabel}
|
||||
size="small" variant="outlined"
|
||||
sx={{
|
||||
fontSize: 10, height: 20,
|
||||
borderColor: vm.fitOutInvestment.netTotal.max > 100000 ? '#d97706' : '#94a3b8',
|
||||
color: vm.fitOutInvestment.netTotal.max > 100000 ? '#92400e' : '#64748b',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{vm.isDivisible && vm.minDivisibleUnitSqm && (
|
||||
<Chip
|
||||
label={`Teilbar ab ${vm.minDivisibleUnitSqm} m²`}
|
||||
size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: '#7c3aed', color: '#7c3aed' }}
|
||||
/>
|
||||
)}
|
||||
{topRisk && (
|
||||
<Chip
|
||||
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
|
||||
@@ -182,7 +211,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||
{vm.tradeoffs[0].description}
|
||||
{vm.tradeoffs[0].concern}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ResultType } from '../../domain/enums'
|
||||
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
|
||||
import type { PropertyUnit } from '../../domain/property'
|
||||
import type { FitOutInvestment } from '../../lib/fitOutUtils'
|
||||
|
||||
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
|
||||
|
||||
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
|
||||
signalIsControlled?: boolean
|
||||
signalAreaSqmEstimate?: number
|
||||
|
||||
// Divisibility (Teilbarkeit)
|
||||
isDivisible?: boolean
|
||||
minDivisibleUnitSqm?: number
|
||||
|
||||
// Fit-out signal
|
||||
fitOutLabel?: string // e.g. "FULL — bezugsfertig", "BASIC + CHF 200 MAB"
|
||||
fitOutViable?: boolean // true when budget bridges the gap
|
||||
fitOutInvestment?: FitOutInvestment
|
||||
|
||||
// Unit-first display (primary)
|
||||
propertySubtitle?: string // building name shown below the unit title
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
||||
export { MatchActionToolbar } from './MatchActionToolbar'
|
||||
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
||||
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||
export type {
|
||||
MatchCardViewModel,
|
||||
MatchCardVariant,
|
||||
|
||||
@@ -70,9 +70,12 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{need && (
|
||||
<Chip
|
||||
label={need.companyName}
|
||||
label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
|
||||
size="small"
|
||||
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }}
|
||||
sx={need.isAnonymous
|
||||
? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' }
|
||||
: { bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
|
||||
@@ -32,9 +32,14 @@ export function NeedSelectionPanel({ matches }: Props) {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3, flex: 1, mr: 0.5 }}>
|
||||
{need.companyName}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, mr: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }} noWrap>
|
||||
{need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
|
||||
</Typography>
|
||||
{need.isAnonymous && (
|
||||
<Chip label="Anonym" size="small" sx={{ bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 9, height: 16, fontWeight: 700, flexShrink: 0, border: '1px solid #ddd6fe' }} />
|
||||
)}
|
||||
</Box>
|
||||
{pendingCount > 0 && (
|
||||
<Chip label={pendingCount} size="small"
|
||||
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { HardHat } from 'lucide-react'
|
||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
|
||||
|
||||
const FIT_OUT_LABELS: Record<string, string> = {
|
||||
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
|
||||
}
|
||||
|
||||
const AMORTIZATION_YEARS = 5
|
||||
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
|
||||
|
||||
interface Props {
|
||||
fitOut: string
|
||||
areaSqm: number
|
||||
mabPerSqm: number
|
||||
rentPricePerSqm: number
|
||||
tenantBudgetPerSqm?: number
|
||||
}
|
||||
|
||||
function chf(value: number): string {
|
||||
return `CHF ${Math.round(value).toLocaleString('de-CH')}.–`
|
||||
}
|
||||
|
||||
function chfRange(min: number, max: number): string {
|
||||
if (Math.round(min) === Math.round(max)) return chf(min)
|
||||
return `${chf(min)} – ${chf(max)}`
|
||||
}
|
||||
|
||||
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
|
||||
|
||||
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
|
||||
py: 0.875, borderBottom: isTotal ? 'none' : `1px solid ${DS_BORDER.default}`,
|
||||
bgcolor: isTotal ? (isWarning ? DS_SURFACE.warning.bg : DS_SURFACE.success.bg) : 'transparent',
|
||||
px: isTotal ? 1.5 : 0, mx: isTotal ? -1.5 : 0, borderRadius: isTotal ? 1 : 0,
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 400 }}>{label}</Typography>
|
||||
{sub && <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>{sub}</Typography>}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 500, color: isTotal ? DS_TEXT.primary : DS_TEXT.secondary, flexShrink: 0, ml: 2 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
|
||||
const rentPerYear = rentPricePerSqm * areaSqm
|
||||
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
|
||||
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
|
||||
|
||||
const investment = isReadyToMoveIn
|
||||
? null
|
||||
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
|
||||
|
||||
const fitOutPerYear = investment && !investment.isFullyCovered ? {
|
||||
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
|
||||
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
|
||||
} : { min: 0, max: 0 }
|
||||
|
||||
const totalPerYear = {
|
||||
min: rentPerYear + fitOutPerYear.min,
|
||||
max: rentPerYear + fitOutPerYear.max,
|
||||
}
|
||||
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
|
||||
const disclaimer = isReadyToMoveIn
|
||||
? null
|
||||
: 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.'
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<HardHat size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
|
||||
{!isReadyToMoveIn && (
|
||||
<Chip
|
||||
label="CRB/BKP Richtwerte"
|
||||
size="small"
|
||||
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, fontSize: 10, height: 20, border: `1px solid ${DS_SURFACE.blue.border}` }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
<Row
|
||||
label="Jahresmiete"
|
||||
value={chf(rentPerYear)}
|
||||
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`}
|
||||
/>
|
||||
{isReadyToMoveIn ? (
|
||||
<Row
|
||||
label="Ausbau"
|
||||
value="CHF 0.–"
|
||||
sub={`${fitOutLabel} — bezugsfertig`}
|
||||
/>
|
||||
) : investment?.isFullyCovered ? (
|
||||
<Row
|
||||
label="Ausbau (amort.)"
|
||||
value="CHF 0.–"
|
||||
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
|
||||
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
|
||||
sub={
|
||||
`CHF ${investment?.grossPerSqm.min}–${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
|
||||
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
|
||||
` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Total/Jahr"
|
||||
value={chfRange(totalPerYear.min, totalPerYear.max)}
|
||||
isTotal
|
||||
isWarning={isWarning}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{disclaimer && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
|
||||
{disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,10 @@ interface MatchDetailHeroProps {
|
||||
keyFacts: Array<{ label: string; value: string }>
|
||||
onCompare: () => void
|
||||
onShortlist: () => void
|
||||
searchCenterLat?: number
|
||||
searchCenterLng?: number
|
||||
searchRadiusKm?: number
|
||||
searchLabel?: string
|
||||
}
|
||||
|
||||
export function MatchDetailHero({
|
||||
@@ -32,6 +36,10 @@ export function MatchDetailHero({
|
||||
keyFacts,
|
||||
onCompare,
|
||||
onShortlist,
|
||||
searchCenterLat,
|
||||
searchCenterLng,
|
||||
searchRadiusKm,
|
||||
searchLabel,
|
||||
}: MatchDetailHeroProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -51,6 +59,10 @@ export function MatchDetailHero({
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={340}
|
||||
searchCenterLat={searchCenterLat}
|
||||
searchCenterLng={searchCenterLng}
|
||||
searchRadiusKm={searchRadiusKm}
|
||||
searchLabel={searchLabel}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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, HardHat, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||
import { FloorPlanSection } from './FloorPlanSection'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||
|
||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||
|
||||
@@ -63,6 +63,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
|
||||
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
|
||||
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')} m²`} />}
|
||||
{property.hardFacts?.fitOut && (() => {
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
|
||||
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
|
||||
return <KeyFactRow label="Ausbaustandard" value={mab ? `${base} + CHF ${mab}/m² MAB` : base} />
|
||||
})()}
|
||||
</Paper>
|
||||
|
||||
{/* Eigenschaften */}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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'
|
||||
import { lookupCityCoords } from '../../lib/locationIntelligence'
|
||||
|
||||
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)
|
||||
|
||||
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
|
||||
const searchLabel = need?.preferredLocations?.[0]
|
||||
|
||||
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}
|
||||
searchCenterLat={searchCenter?.lat}
|
||||
searchCenterLng={searchCenter?.lng}
|
||||
searchRadiusKm={need?.searchRadius}
|
||||
searchLabel={searchLabel}
|
||||
/>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ExternalLink,
|
||||
Info,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
Tag,
|
||||
Train,
|
||||
TrendingUp,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react'
|
||||
import { Banknote, Calendar, HardHat, MapPin, Maximize2, Tag } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
@@ -42,6 +42,16 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
|
||||
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '–' },
|
||||
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
|
||||
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '–' },
|
||||
...(property?.hardFacts?.fitOut ? [{
|
||||
icon: <HardHat size={15} />,
|
||||
label: 'Ausbaustandard',
|
||||
value: (() => {
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
|
||||
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
|
||||
return mab ? `${base} + CHF ${mab}/m² MAB` : base
|
||||
})(),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||
export { MatchDetailHero } from './MatchDetailHero'
|
||||
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
|
||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
||||
@@ -10,4 +12,5 @@ export { MissingInformationPanel } from './MissingInformationPanel'
|
||||
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
||||
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
||||
export { NextActionsPanel } from './NextActionsPanel'
|
||||
export { FitOutCostPanel } from './FitOutCostPanel'
|
||||
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
||||
|
||||
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
|
||||
<TextField
|
||||
label="Fläche (m²)" value={areaSqm}
|
||||
onChange={e => onAreaSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +10,12 @@ interface Props {
|
||||
onParkingChange: (v: string) => void
|
||||
ceilingHeight: string
|
||||
onCeilingHeightChange: (v: string) => void
|
||||
mieterausbaubeitrag: string
|
||||
onMieterausbaubeitragChange: (v: string) => void
|
||||
isFlexible: boolean
|
||||
onIsFlexibleChange: (v: boolean) => void
|
||||
minLettableSqm: string
|
||||
onMinLettableSqmChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function TechnicalDetailsSection({
|
||||
@@ -17,6 +23,9 @@ export function TechnicalDetailsSection({
|
||||
fitOut, onFitOutChange,
|
||||
parking, onParkingChange,
|
||||
ceilingHeight, onCeilingHeightChange,
|
||||
mieterausbaubeitrag, onMieterausbaubeitragChange,
|
||||
isFlexible, onIsFlexibleChange,
|
||||
minLettableSqm, onMinLettableSqmChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
@@ -35,17 +44,59 @@ export function TechnicalDetailsSection({
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
value={mieterausbaubeitrag}
|
||||
onChange={e => onMieterausbaubeitragChange(e.target.value)}
|
||||
size="small" type="number" fullWidth
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters"
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||
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>
|
||||
|
||||
{/* Divisibility */}
|
||||
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={isFlexible}
|
||||
onChange={e => onIsFlexibleChange(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Fläche ist teilbar</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Mieter können auch nur einen Teil der Fläche mieten
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ m: 0, flex: 1 }}
|
||||
/>
|
||||
{isFlexible && (
|
||||
<TextField
|
||||
label="Mindesteinheit (m²)"
|
||||
value={minLettableSqm}
|
||||
onChange={e => onMinLettableSqmChange(e.target.value)}
|
||||
size="small"
|
||||
type="number"
|
||||
sx={{ width: 180 }}
|
||||
slotProps={{ htmlInput: { min: 10, step: 10 } }}
|
||||
helperText="Kleinste vermietbare Einheit"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
disableEscapeKeyDown
|
||||
>
|
||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||
{SLIDES[activeStep]}
|
||||
|
||||
@@ -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'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
@@ -12,11 +12,21 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
|
||||
})
|
||||
|
||||
const searchIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:14px;height:14px;border-radius:50%;background:#7c3aed;border:3px solid white;box-shadow:0 2px 6px rgba(124,58,237,0.5)"></div>`,
|
||||
iconAnchor: [7, 7],
|
||||
})
|
||||
|
||||
interface Props {
|
||||
lat: number
|
||||
lng: number
|
||||
label?: string
|
||||
height?: number
|
||||
searchCenterLat?: number
|
||||
searchCenterLng?: number
|
||||
searchRadiusKm?: number
|
||||
searchLabel?: string
|
||||
}
|
||||
|
||||
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
||||
@@ -27,34 +37,85 @@ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
||||
return null
|
||||
}
|
||||
|
||||
export function PropertyMap({ lat, lng, label, height = 260 }: Props) {
|
||||
function FitToRadius({ propLat, propLng, searchLat, searchLng, radiusKm }: {
|
||||
propLat: number; propLng: number
|
||||
searchLat: number; searchLng: number; radiusKm: number
|
||||
}) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const circleBounds = L.latLng(searchLat, searchLng).toBounds(radiusKm * 1000 * 2)
|
||||
const bounds = circleBounds.extend([propLat, propLng])
|
||||
map.fitBounds(bounds, { padding: [30, 30] })
|
||||
}, [map, propLat, propLng, searchLat, searchLng, radiusKm])
|
||||
return null
|
||||
}
|
||||
|
||||
export function PropertyMap({ lat, lng, label, height = 260, searchCenterLat, searchCenterLng, searchRadiusKm, searchLabel }: Props) {
|
||||
const hasSearch = searchCenterLat !== undefined && searchCenterLng !== undefined && searchRadiusKm !== undefined
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
// Ensure leaflet controls appear above MUI elements
|
||||
<Box sx={{ position: 'relative', height, width: '100%', overflow: 'hidden', flexShrink: 0,
|
||||
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
||||
'& .leaflet-control': { zIndex: 1 },
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<MapContainer
|
||||
center={[lat, lng]}
|
||||
zoom={14}
|
||||
zoom={hasSearch ? 10 : 14}
|
||||
scrollWheelZoom
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<RecenterOnChange lat={lat} lng={lng} />
|
||||
{hasSearch
|
||||
? <FitToRadius propLat={lat} propLng={lng} searchLat={searchCenterLat!} searchLng={searchCenterLng!} radiusKm={searchRadiusKm!} />
|
||||
: <RecenterOnChange lat={lat} lng={lng} />
|
||||
}
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Search radius circle */}
|
||||
{hasSearch && (
|
||||
<Circle
|
||||
center={[searchCenterLat!, searchCenterLng!]}
|
||||
radius={searchRadiusKm! * 1000}
|
||||
pathOptions={{ color: '#7c3aed', fillColor: '#7c3aed', fillOpacity: 0.07, weight: 2, dashArray: '6 4' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search center marker */}
|
||||
{hasSearch && (
|
||||
<Marker position={[searchCenterLat!, searchCenterLng!]} icon={searchIcon}>
|
||||
<Popup>{searchLabel ?? `Suchradius: ${searchRadiusKm} km`}</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* Property marker */}
|
||||
<Marker position={[lat, lng]}>
|
||||
{label && <Popup>{label}</Popup>}
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend overlay */}
|
||||
{hasSearch && (
|
||||
<Box sx={{
|
||||
position: 'absolute', bottom: 8, left: 8, zIndex: 500,
|
||||
bgcolor: 'rgba(255,255,255,0.92)', borderRadius: 1.5,
|
||||
px: 1.25, py: 0.625, display: 'flex', flexDirection: 'column', gap: 0.4,
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#7c3aed', flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.67rem', color: '#5b21b6', fontWeight: 700 }}>
|
||||
{searchLabel ?? 'Suchzentrum'} · {searchRadiusKm} km Radius
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#1e3a5f', flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.67rem', color: '#334155' }}>Objekt</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp
|
||||
onClose={ready ? onClose : undefined}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
|
||||
slotProps={{ paper: { sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } } }}
|
||||
>
|
||||
{/* Header */}
|
||||
<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, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
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 })}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<FieldGrid>
|
||||
<Field label="Aktueller Mieter" value={p.currentTenant} />
|
||||
<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="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
||||
</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 */}
|
||||
@@ -121,15 +179,59 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
|
||||
{/* Object details */}
|
||||
<SectionTitle title="Objekt & Lage" />
|
||||
{editing ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
||||
>
|
||||
{[
|
||||
{ value: '', label: 'Keine Angabe' },
|
||||
{ value: 'SHELL', label: 'Rohbau' },
|
||||
{ value: 'BASIC', label: 'Basisausbau' },
|
||||
{ value: 'FULL', label: 'Vollausbau' },
|
||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters zum Ausbau"
|
||||
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && (
|
||||
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
|
||||
)}
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
</FieldGrid>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{p.location.coordinates && (
|
||||
|
||||
@@ -28,11 +28,13 @@ import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
|
||||
interface PropertyDetailViewProps {
|
||||
propertyId: string
|
||||
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 [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<UpdatePropertyInput>({})
|
||||
@@ -175,8 +177,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
</Tabs>
|
||||
|
||||
{/* Tab content */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: tab === 0 ? 2.5 : 0 }}>
|
||||
{tab === 0 && (
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: TABS[tab] === 'Übersicht' ? 2.5 : 0 }}>
|
||||
{TABS[tab] === 'Übersicht' && (
|
||||
<PropertyDetailOverview
|
||||
p={property}
|
||||
editing={editing}
|
||||
@@ -184,8 +186,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
onDraftChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
{tab === 1 && <MatchabilityTabPanel propertyId={propertyId} />}
|
||||
{tab === 2 && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
||||
{TABS[tab] === 'Matchability' && <MatchabilityTabPanel propertyId={propertyId} />}
|
||||
{TABS[tab] === 'Marktsignale' && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
|
||||
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
@@ -9,133 +9,176 @@ interface Props {
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
function availabilityBadgeColor(status: string): string {
|
||||
if (status === 'AVAILABLE_NOW') return '#1a7a4a'
|
||||
if (status === 'AVAILABLE_SOON') return '#d97706'
|
||||
return '#64748b'
|
||||
function availabilityColor(status: string) {
|
||||
if (status === 'AVAILABLE_NOW') return { bg: '#dcfce7', text: '#15803d' }
|
||||
if (status === 'AVAILABLE_SOON') return { bg: '#fef9c3', text: '#a16207' }
|
||||
return { bg: '#f1f5f9', text: '#64748b' }
|
||||
}
|
||||
|
||||
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
||||
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 (
|
||||
<Box
|
||||
onClick={() => onSelect(p.id)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e8d5c4',
|
||||
background: 'linear-gradient(160deg,#fdf8f3,#faf0e6)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
border: '1px solid #e2e8f0',
|
||||
bgcolor: 'white',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.15s, transform 0.12s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.10)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: '#cbd5e1',
|
||||
},
|
||||
}}
|
||||
onClick={() => onSelect(p.id)}
|
||||
>
|
||||
{/* Image zone */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LocationPreview
|
||||
imageUrl={p.images?.[0]}
|
||||
lat={p.location.coordinates?.lat}
|
||||
lng={p.location.coordinates?.lng}
|
||||
address={`${p.address.street} ${p.address.houseNumber}, ${p.address.city}`}
|
||||
cityLabel={p.location.city}
|
||||
height={175}
|
||||
{/* ── Image / header ── */}
|
||||
<Box sx={{ position: 'relative', height: 120, flexShrink: 0, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||
{hasImage ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={p.images![0]}
|
||||
alt={p.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
|
||||
{/* Availability badge */}
|
||||
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||
size="small"
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: availabilityBadgeColor(p.availabilityStatus),
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
width: '100%', height: '100%',
|
||||
background: `linear-gradient(135deg, ${assetColor}22 0%, ${assetColor}44 100%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Typography sx={{ fontSize: '2.5rem', opacity: 0.3 }}>🏢</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Asset type chip */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
||||
{/* Overlay gradient */}
|
||||
<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
|
||||
label={getAssetTypeLabel(p.assetType)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: getAssetTypeColor(p.assetType),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
}}
|
||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: assetColor, color: 'white', borderRadius: 1 }}
|
||||
/>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: avail.bg, color: avail.text, borderRadius: 1 }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Card body */}
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{/* ── Card body ── */}
|
||||
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
|
||||
|
||||
{/* Title + location */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
|
||||
{p.title}
|
||||
</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}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Key specs */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
||||
<Chip label={`${p.areaSqm} m²`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
<Chip label={`CHF ${p.rentPricePerSqm}/m²/Jahr`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
{p.contractDurationMonths && (
|
||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
{/* Key metrics row */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<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 }}>
|
||||
<Maximize2 size={10} color="#64748b" />
|
||||
<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>
|
||||
|
||||
{/* Soft factors */}
|
||||
{/* Soft factor chips */}
|
||||
{p.softFactors && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
||||
{p.softFactors.prestige != null && (
|
||||
<Chip label={`Prestige ${p.softFactors.prestige}`} size="small"
|
||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{p.softFactors.publicTransportMinutes != null && (
|
||||
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
|
||||
)}
|
||||
{p.softFactors.accessibility != null && (
|
||||
<Chip label={`ÖV ${p.softFactors.publicTransportMinutes ?? p.softFactors.accessibility}min`} size="small"
|
||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
||||
{p.hardFacts?.fitOut && (
|
||||
<Chip label={p.hardFacts.fitOut} size="small"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Confidence */}
|
||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Datenkonfidenz</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||
{/* Confidence bar */}
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.67rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Datenkonfidenz</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={confPct}
|
||||
sx={{
|
||||
height: 5, borderRadius: 3, bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor },
|
||||
height: 4, borderRadius: 2, bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -5,21 +5,33 @@ import { useMarketReport } from '../../hooks/useMarketReport'
|
||||
import type { SignalCategory } from '../../domain/marketReport'
|
||||
import { SignalCard } from './SignalCard'
|
||||
import { BerichtDialog } from './BerichtDialog'
|
||||
import { MarketKpiPanel } from './MarketKpiPanel'
|
||||
|
||||
interface Props {
|
||||
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: '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' },
|
||||
]
|
||||
|
||||
export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||
const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
|
||||
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) {
|
||||
return (
|
||||
@@ -58,7 +70,17 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
|
||||
</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)
|
||||
if (signals.length === 0) return null
|
||||
return (
|
||||
@@ -77,7 +99,8 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{dialogOpen && (
|
||||
|
||||
@@ -12,18 +12,20 @@ import {
|
||||
FormControlLabel,
|
||||
Tooltip,
|
||||
} 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 { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
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() {
|
||||
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
||||
const { data: reminder } = useReminder(selectedId ?? '')
|
||||
const { data: reminderProperty } = usePropertyById(reminder?.propertyId ?? '')
|
||||
const complete = useCompleteReminder()
|
||||
const dismiss = useDismissReminder()
|
||||
const snooze = useSnoozeReminder()
|
||||
@@ -101,6 +103,27 @@ export function ReminderDetailDrawer() {
|
||||
</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>
|
||||
{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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { useReminders } from '../../hooks/useReminders'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
@@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard'
|
||||
import { ReminderSkeleton } from './ReminderSkeleton'
|
||||
import { ReminderEmptyState } from './ReminderEmptyState'
|
||||
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(
|
||||
reminders: Reminder[],
|
||||
filterType: string,
|
||||
filterStatus: string,
|
||||
filterPriority: string,
|
||||
filterHorizon: string,
|
||||
searchQuery: string,
|
||||
): Reminder[] {
|
||||
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 (filterStatus !== 'ALL' && r.status !== filterStatus) return false
|
||||
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
|
||||
if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||
if (!haystack.includes(q)) return false
|
||||
const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -34,38 +57,60 @@ function applyFilters(
|
||||
export function ReminderFeed() {
|
||||
const { data, isLoading } = useReminders()
|
||||
const {
|
||||
filterType, filterStatus, filterPriority, searchQuery, viewMode,
|
||||
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery,
|
||||
filterType, filterStatus, filterHorizon, searchQuery, viewMode,
|
||||
setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery,
|
||||
} = useReminderStore(useShallow(s => ({
|
||||
filterType: s.filterType, filterStatus: s.filterStatus,
|
||||
filterPriority: s.filterPriority, searchQuery: s.searchQuery,
|
||||
filterHorizon: s.filterHorizon, searchQuery: s.searchQuery,
|
||||
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
||||
setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority,
|
||||
setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon,
|
||||
setSearchQuery: s.setSearchQuery,
|
||||
})))
|
||||
|
||||
const reminders = data ?? []
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
|
||||
[reminders, filterType, filterStatus, filterPriority, searchQuery],
|
||||
() => applyFilters(reminders, filterType, filterStatus, filterHorizon, 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(() => {
|
||||
setFilterType('ALL')
|
||||
setFilterStatus('ALL')
|
||||
setFilterPriority('ALL')
|
||||
setFilterStatus('ACTIVE')
|
||||
setFilterHorizon('ALL')
|
||||
setSearchQuery('')
|
||||
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
|
||||
}, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery])
|
||||
|
||||
if (isLoading) return <ReminderSkeleton />
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return <ReminderEmptyState onReset={resetFilters} />
|
||||
}
|
||||
if (filtered.length === 0) return <ReminderEmptyState onReset={resetFilters} />
|
||||
|
||||
if (viewMode === 'card') {
|
||||
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
|
||||
sx={{
|
||||
display: 'grid',
|
||||
@@ -73,20 +118,22 @@ export function ReminderFeed() {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{filtered.map(r => (
|
||||
<ReminderCard key={r.id} reminder={r} />
|
||||
))}
|
||||
{group.map(r => <ReminderCard key={r.id} reminder={r} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
{/* Table header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: LIST_HEADER_COLS,
|
||||
gridTemplateColumns: LIST_COLS,
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
@@ -101,9 +148,43 @@ export function ReminderFeed() {
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{filtered.map(r => (
|
||||
<ReminderListRow key={r.id} reminder={r} />
|
||||
))}
|
||||
{/* Grouped rows */}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { useShallow } from 'zustand/react/shallow'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
||||
import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder'
|
||||
import { ReminderType } from '../../domain/reminder'
|
||||
import type { ReminderType as ReminderTypeType } from '../../domain/reminder'
|
||||
|
||||
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||
LEASE_EXPIRY: 'Mietablauf',
|
||||
@@ -16,38 +16,23 @@ const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||
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() {
|
||||
const {
|
||||
filterType, setFilterType,
|
||||
filterStatus, setFilterStatus,
|
||||
filterPriority, setFilterPriority,
|
||||
searchQuery, setSearchQuery,
|
||||
viewMode, setViewMode,
|
||||
} = useReminderStore(useShallow(s => ({
|
||||
filterType: s.filterType, setFilterType: s.setFilterType,
|
||||
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
||||
filterPriority: s.filterPriority, setFilterPriority: s.setFilterPriority,
|
||||
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
||||
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
||||
})))
|
||||
|
||||
const showArchive = filterStatus === 'ALL'
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-3">
|
||||
<Box className="flex items-center gap-3 flex-wrap">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{/* Search */}
|
||||
<TextField
|
||||
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 */}
|
||||
@@ -80,64 +101,5 @@ export function ReminderFilterBar() {
|
||||
</ToggleButtonGroup>
|
||||
</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 { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||
import { Box, Typography, Skeleton } from '@mui/material'
|
||||
import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
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
|
||||
label: string
|
||||
value: number | string
|
||||
color: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
||||
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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,
|
||||
'&:hover': {
|
||||
bgcolor: `${color}0d`,
|
||||
borderColor: color,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 1,
|
||||
bgcolor: `${color}18`,
|
||||
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' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap', fontSize: '0.7rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReminderKpiBar() {
|
||||
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) {
|
||||
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 (
|
||||
<Box className="flex gap-3">
|
||||
<KpiItem
|
||||
icon={<AlertTriangle size={18} color="#dc2626" />}
|
||||
label="Dringend"
|
||||
value={insights.urgentCount}
|
||||
<KpiCard
|
||||
icon={<AlertOctagon size={18} color="#dc2626" />}
|
||||
label="Überfällig"
|
||||
value={ins.overdueCount}
|
||||
color="#dc2626"
|
||||
active={filterHorizon === 'OVERDUE'}
|
||||
onClick={() => toggleHorizon('OVERDUE')}
|
||||
/>
|
||||
<KpiItem
|
||||
<KpiCard
|
||||
icon={<Calendar size={18} color="#ea580c" />}
|
||||
label="Diese Woche"
|
||||
value={insights.dueThisWeek}
|
||||
value={ins.dueThisWeek}
|
||||
color="#ea580c"
|
||||
active={filterHorizon === 'THIS_WEEK'}
|
||||
onClick={() => toggleHorizon('THIS_WEEK')}
|
||||
/>
|
||||
<KpiItem
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={18} color="#0369a1" />}
|
||||
label="Dieser Monat"
|
||||
value={insights.dueThisMonth}
|
||||
value={ins.dueThisMonth}
|
||||
color="#0369a1"
|
||||
active={filterHorizon === 'THIS_MONTH'}
|
||||
onClick={() => toggleHorizon('THIS_MONTH')}
|
||||
/>
|
||||
<KpiItem
|
||||
<KpiCard
|
||||
icon={<Eye size={18} color="#be185d" />}
|
||||
label="Pre-Market Risiko"
|
||||
value={insights.schattenmarktReadyCount}
|
||||
value={ins.schattenmarktReadyCount}
|
||||
color="#be185d"
|
||||
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
|
||||
onClick={togglePreMarket}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,14 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||
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'> = {
|
||||
ACTIVE: 'info',
|
||||
@@ -56,6 +63,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
||||
}
|
||||
|
||||
const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent'
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={handleRowClick}
|
||||
@@ -64,9 +73,11 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
pl: 1.5,
|
||||
pr: 2,
|
||||
py: 1.25,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
borderLeft: `3px solid ${borderColor}`,
|
||||
bgcolor: 'white',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
|
||||
@@ -27,6 +28,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
const navigate = useNavigate()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
||||
const updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||
|
||||
@@ -114,9 +118,65 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
{u.available ? (
|
||||
<>
|
||||
<Chip label="Frei" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: DS_SURFACE.success.bg, color: '#166534', border: `1px solid ${DS_SURFACE.success.border}` }} />
|
||||
{u.isFlexible && (
|
||||
<Chip label="Teilfläche möglich" size="small" sx={{ height: 16, fontSize: '0.58rem', bgcolor: DS_SURFACE.blue.bg, color: '#1d4ed8', border: `1px solid ${DS_SURFACE.blue.border}` }} />
|
||||
|
||||
{/* Teilbar toggle */}
|
||||
<Tooltip title={u.isFlexible ? 'Teilbar — Mindesteinheit bearbeiten' : 'Als teilbar markieren'}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={u.isFlexible ?? false}
|
||||
onChange={e => {
|
||||
const flexible = e.target.checked
|
||||
if (flexible) {
|
||||
setEditingFlexUnit(u.id)
|
||||
setFlexDraft(d => ({ ...d, [u.id]: u.minLettableSqm }))
|
||||
} else {
|
||||
updateUnit.mutate({ unitId: u.id, data: { isFlexible: false, minLettableSqm: undefined } })
|
||||
setEditingFlexUnit(null)
|
||||
}
|
||||
}}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#1d4ed8' } }}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.58rem', color: u.isFlexible ? '#1d4ed8' : DS_TEXT.disabled, fontWeight: u.isFlexible ? 700 : 400 }}>
|
||||
Teilbar
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
{/* Inline min-unit editor */}
|
||||
{editingFlexUnit === u.id && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="Min m²"
|
||||
value={flexDraft[u.id] ?? ''}
|
||||
onChange={e => setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))}
|
||||
sx={{ width: 72, '& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 } }}
|
||||
slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }}
|
||||
onClick={() => {
|
||||
updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } })
|
||||
setEditingFlexUnit(null)
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.5, minWidth: 0, color: DS_TEXT.muted }}
|
||||
onClick={() => setEditingFlexUnit(null)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
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 {
|
||||
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
|
||||
url?: string
|
||||
@@ -58,4 +68,7 @@ export interface FutureSignal {
|
||||
|
||||
// Unit-level reference: if set, this signal is about a specific rentable unit
|
||||
unitId?: string
|
||||
|
||||
// KI-extracted contact information from web crawler sources
|
||||
extractedContacts?: ExtractedContact[]
|
||||
}
|
||||
|
||||
@@ -11,8 +11,32 @@ export interface PropertyMarketSignal {
|
||||
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 {
|
||||
propertyId: string
|
||||
generatedAt: string
|
||||
assetTypeLabel: string // e.g. "Büro"
|
||||
kpis: MarketKpis // base values at 5 km radius
|
||||
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'>
|
||||
@@ -92,6 +92,11 @@ export interface Need {
|
||||
requireBarrierFree?: boolean
|
||||
minCeilingHeightM?: number
|
||||
minContractDurationMonths?: number
|
||||
searchRadius?: number
|
||||
isAnonymous?: boolean
|
||||
requiresDivisibility?: boolean
|
||||
minDivisibleUnit?: number
|
||||
fitOutBudgetMaxPerSqm?: number
|
||||
|
||||
softFactors?: SoftFactorPreferences
|
||||
weightingProfile: WeightingProfile
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface ParsedNeedCriteria {
|
||||
footfallNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
companyName?: string
|
||||
notes?: string
|
||||
searchRadius?: number
|
||||
isAnonymous?: boolean
|
||||
requiresDivisibility?: boolean
|
||||
minDivisibleUnit?: number
|
||||
fitOutBudgetMaxPerSqm?: number
|
||||
}
|
||||
|
||||
export interface FollowUpQuestion {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
||||
} 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
|
||||
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
||||
@@ -53,6 +54,7 @@ export interface DataQuality {
|
||||
export interface PropertyHardFacts {
|
||||
floor?: number
|
||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||
mieterausbaubeitragPerSqm?: number
|
||||
parking?: number
|
||||
publicTransportScore?: number
|
||||
usageType?: string
|
||||
@@ -135,6 +137,8 @@ export interface Property {
|
||||
description?: string
|
||||
images?: string[]
|
||||
floorPlanUrl?: string
|
||||
leaseContractUrl?: string // link to the signed lease document
|
||||
leaseContractName?: string // display label, e.g. "Mietvertrag 2021–2026"
|
||||
|
||||
propertyNumber?: string
|
||||
units?: PropertyUnit[]
|
||||
@@ -150,7 +154,7 @@ export interface Property {
|
||||
importedAt?: string
|
||||
lastUpdatedAt?: string
|
||||
|
||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number }
|
||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
|
||||
|
||||
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
||||
lastReviewedAt?: string
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
||||
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 =
|
||||
| VerifiedPortfolioResult
|
||||
| FutureAvailabilityResult
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
||||
|
||||
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||
expect(vm.scoreBreakdown!.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||
expect(vm.scoreBreakdown!.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||
expect(vm.scoreBreakdown!.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||
})
|
||||
|
||||
it('does NOT set a disclaimer for verified portfolio results', () => {
|
||||
@@ -65,7 +65,7 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
expect(vm.actions).toBe(actions)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../../components/match-card/MatchCardViewModel'
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
|
||||
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||
|
||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||
|
||||
@@ -137,6 +138,38 @@ export function buildMatchCardViewModel(
|
||||
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
|
||||
preMarketUnit: unit,
|
||||
preMarketAllUnits: property?.units,
|
||||
fitOutLabel: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!fitOut) return undefined
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
|
||||
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
|
||||
return LABELS[fitOut] ?? fitOut
|
||||
})(),
|
||||
fitOutViable: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!fitOut) return undefined
|
||||
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
if (fitOut === 'BASIC' && mab >= 150) return true
|
||||
if (fitOut === 'SHELL' && mab >= 350) return true
|
||||
return undefined
|
||||
})(),
|
||||
fitOutInvestment: (() => {
|
||||
const fitOut = property?.hardFacts?.fitOut
|
||||
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
const area = property.areaSqm ?? 0
|
||||
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
|
||||
})(),
|
||||
isDivisible: property?.units ? property.units.length > 1 : false,
|
||||
minDivisibleUnitSqm: (() => {
|
||||
if (!property?.units || property.units.length <= 1) return undefined
|
||||
const flexible = property.units.filter(u => u.isFlexible && u.minLettableSqm)
|
||||
if (flexible.length > 0) return Math.min(...flexible.map(u => u.minLettableSqm!))
|
||||
return Math.min(...property.units.map(u => u.areaSqm))
|
||||
})(),
|
||||
scoreBreakdown: {
|
||||
hardMatchScore: match.scoreBreakdown.hardMatchScore,
|
||||
softFactorScore: match.scoreBreakdown.softFactorScore,
|
||||
|
||||
@@ -106,6 +106,22 @@ const RULES: KeywordRule[] = [
|
||||
check: (_p) => null, // no structured field yet — always UNKNOWN
|
||||
explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst',
|
||||
},
|
||||
{
|
||||
keywords: ['ausbaugrad', 'ausbaustandard', 'vollausbau', 'modern ausgebaut', 'plug and play', 'bezugsfertig', 'gehoben ausgebaut'],
|
||||
check: (p) => {
|
||||
const fitOut = p.hardFacts?.fitOut
|
||||
if (!fitOut) return null
|
||||
return fitOut === 'FULL' || fitOut === 'PREMIUM'
|
||||
},
|
||||
explanation: (p, ok) => {
|
||||
const fitOut = p.hardFacts?.fitOut
|
||||
if (!fitOut) return 'Ausbaustandard nicht dokumentiert'
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
return ok
|
||||
? `${LABELS[fitOut] ?? fitOut} — moderner Ausbaustandard erfüllt`
|
||||
: `${LABELS[fitOut] ?? fitOut} — nicht auf modernem Ausbauniveau`
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Number extraction helpers ──────────────────────────────────────────────────
|
||||
@@ -115,10 +131,25 @@ function extractNumber(text: string): number | null {
|
||||
return m ? parseInt(m[1], 10) : null
|
||||
}
|
||||
|
||||
function extractMinuteLimit(text: string): number | null {
|
||||
const m = text.match(/(?:unter|<|≤|max\.?)\s*(\d+)/) ?? text.match(/(\d+)\s*min/i)
|
||||
return m ? parseInt(m[1], 10) : null
|
||||
}
|
||||
|
||||
function isParkingKeyword(text: string): boolean {
|
||||
return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
function isOevKeyword(text: string): boolean {
|
||||
return text.includes('öv-anbindung') || text.includes('öv anbindung') ||
|
||||
text.includes('öv-anschluss') || text.includes('öffentlicher verkehr') ||
|
||||
(text.includes('öv') && (text.includes('min') || text.includes('anbindung')))
|
||||
}
|
||||
|
||||
function isMindestflaecheKeyword(text: string): boolean {
|
||||
return text.includes('mindestfläche') || text.includes('mindestnutzfläche') || text.includes('mindest-fläche')
|
||||
}
|
||||
|
||||
// ── Main scorer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function scoreMustHaves(
|
||||
@@ -137,6 +168,41 @@ export function scoreMustHaves(
|
||||
const results: MustHaveResult[] = criteria.map((raw) => {
|
||||
const lower = raw.toLowerCase()
|
||||
|
||||
// Special case: ÖV-Anbindung with minute limit ("< 5 Min", "unter 10 Min")
|
||||
if (isOevKeyword(lower)) {
|
||||
const minutes = property.softFactors?.publicTransportMinutes
|
||||
if (minutes === undefined) {
|
||||
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'ÖV-Fahrzeit nicht dokumentiert' }
|
||||
}
|
||||
const limit = extractMinuteLimit(lower) ?? 10
|
||||
const passed = minutes <= limit
|
||||
return {
|
||||
criterion: raw,
|
||||
passed,
|
||||
confidence: 'CERTAIN' as const,
|
||||
explanation: passed
|
||||
? `${minutes} Min. zu Fuss zum ÖV — Limit ${limit} Min. erfüllt`
|
||||
: `${minutes} Min. zu Fuss — überschreitet ${limit}-Min.-Limit`,
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: Mindestfläche with m² threshold ("Mindestfläche 600m²")
|
||||
if (isMindestflaecheKeyword(lower)) {
|
||||
const required = extractNumber(lower)
|
||||
if (required === null) {
|
||||
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'Mindestfläche konnte nicht ausgelesen werden' }
|
||||
}
|
||||
const passed = property.areaSqm >= required
|
||||
return {
|
||||
criterion: raw,
|
||||
passed,
|
||||
confidence: 'CERTAIN' as const,
|
||||
explanation: passed
|
||||
? `${property.areaSqm.toLocaleString('de-CH')} m² deckt Mindestfläche ${required} m² ab`
|
||||
: `${property.areaSqm.toLocaleString('de-CH')} m² liegt unter Mindestfläche ${required} m²`,
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: parking with minimum count ("mind. 5 parkplätze")
|
||||
if (isParkingKeyword(lower)) {
|
||||
const required = extractNumber(lower)
|
||||
|
||||
@@ -463,24 +463,50 @@ function scoreMinContractDuration(need: Need, property: Property): ScoreFactor |
|
||||
}
|
||||
}
|
||||
|
||||
const FIT_OUT_UPGRADE_COST: Record<string, number> = {
|
||||
'SHELL_BASIC': 200,
|
||||
'SHELL_FULL': 350,
|
||||
'SHELL_PREMIUM': 450,
|
||||
'BASIC_FULL': 150,
|
||||
'BASIC_PREMIUM': 250,
|
||||
'FULL_PREMIUM': 100,
|
||||
}
|
||||
|
||||
function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
|
||||
if (!need.requiredFitOut) return null
|
||||
const propFitOut = property.hardFacts?.fitOut
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
if (!propFitOut) {
|
||||
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
|
||||
}
|
||||
const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
|
||||
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
|
||||
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
if (propLevel >= reqLevel) {
|
||||
return {
|
||||
criterion: 'fitOut',
|
||||
weight: 0.04,
|
||||
score,
|
||||
contribution: score * 0.04,
|
||||
explanation: propLevel >= reqLevel
|
||||
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
|
||||
: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
|
||||
criterion: 'fitOut', weight: 0.04, score: 100, contribution: 4,
|
||||
explanation: `${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung (${LABELS[need.requiredFitOut] ?? need.requiredFitOut})`,
|
||||
}
|
||||
}
|
||||
// Check if combined budget (tenant + MAB) covers the upgrade gap
|
||||
const upgradeKey = `${propFitOut}_${need.requiredFitOut}`
|
||||
const upgradeCost = FIT_OUT_UPGRADE_COST[upgradeKey]
|
||||
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||
const tenantBudget = need.fitOutBudgetMaxPerSqm ?? 0
|
||||
const totalBudget = mab + tenantBudget
|
||||
if (upgradeCost && totalBudget >= upgradeCost) {
|
||||
const mabNote = mab > 0 ? ` (inkl. CHF ${mab} MAB)` : ''
|
||||
return {
|
||||
criterion: 'fitOut', weight: 0.04, score: 80, contribution: 3.2,
|
||||
explanation: `${LABELS[propFitOut]} — Ausbau auf ${LABELS[need.requiredFitOut]} mit CHF ${totalBudget}/m² Budget überbrückbar${mabNote}`,
|
||||
}
|
||||
}
|
||||
const score = Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
|
||||
const budgetNote = tenantBudget > 0 || mab > 0
|
||||
? ` (Budget CHF ${totalBudget}/m² — Ausbau ~CHF ${upgradeCost ?? '?'}/m²)`
|
||||
: ''
|
||||
return {
|
||||
criterion: 'fitOut', weight: 0.04, score, contribution: score * 0.04,
|
||||
explanation: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich${budgetNote}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ describe('useCreateNeed', () => {
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
||||
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))
|
||||
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
||||
|
||||
@@ -71,9 +71,14 @@ describe('useCreateProperty', () => {
|
||||
assetType: 'LOGISTICS',
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
location: { city: 'Bern', country: 'CH' },
|
||||
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '3000', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 300,
|
||||
rentPricePerSqm: 100,
|
||||
availabilityDate: '2025-01-01',
|
||||
availabilityStatus: 'AVAILABLE_NOW',
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.8,
|
||||
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
|
||||
organizationId: 'org-test',
|
||||
})
|
||||
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 { needService } from '../services/needService'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import type { ItemResponse } from '../services/types'
|
||||
import type { Property } from '../domain/property'
|
||||
import type { Need } from '../domain/need'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
@@ -13,14 +14,14 @@ export function useMatchDetailData(matchId: string) {
|
||||
const { data: match, isLoading } = useMatchDetail(matchId)
|
||||
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],
|
||||
queryFn: () => propertyService.getById(match!.propertyId),
|
||||
enabled: !!match && !isFuture,
|
||||
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],
|
||||
queryFn: () => needService.getById(match!.needId),
|
||||
enabled: !!match?.needId,
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface NewListingFormState {
|
||||
fitOut: string
|
||||
parking: string
|
||||
ceilingHeight: string
|
||||
mieterausbaubeitrag: string
|
||||
isFlexible: boolean
|
||||
minLettableSqm: string
|
||||
// Contact
|
||||
contactName: string
|
||||
contactEmail: string
|
||||
@@ -79,6 +82,9 @@ export interface NewListingFormHandlers {
|
||||
setFitOut: (v: string) => void
|
||||
setParking: (v: string) => void
|
||||
setCeilingHeight: (v: string) => void
|
||||
setMieterausbaubeitrag: (v: string) => void
|
||||
setIsFlexible: (v: boolean) => void
|
||||
setMinLettableSqm: (v: string) => void
|
||||
setContactName: (v: string) => void
|
||||
setContactEmail: (v: string) => void
|
||||
setContactPhone: (v: string) => void
|
||||
@@ -113,6 +119,9 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
const [fitOut, setFitOut] = useState('')
|
||||
const [parking, setParking] = useState('')
|
||||
const [ceilingHeight, setCeilingHeight]= useState('')
|
||||
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState('')
|
||||
const [isFlexible, setIsFlexible] = useState(false)
|
||||
const [minLettableSqm, setMinLettableSqm] = useState('')
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [imageInput, setImageInput] = useState('')
|
||||
const [floorPlanUrl, setFloorPlanUrl] = useState('')
|
||||
@@ -161,6 +170,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||
availableFrom, description, softLevels,
|
||||
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||
mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => setCreated(true),
|
||||
@@ -176,6 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||
setSoftLevels(emptySoftLevels())
|
||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
|
||||
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||
setAiText(''); setAiApplied(false)
|
||||
setCreated(false); setError(null)
|
||||
@@ -184,7 +195,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
return {
|
||||
assetType, areaSqm, rentPerSqm, availableFrom, description,
|
||||
street, houseNumber, postalCode, city,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||
contactName, contactEmail, contactPhone,
|
||||
images, imageInput, floorPlanUrl, aiText, aiApplied,
|
||||
error, created,
|
||||
@@ -194,7 +205,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
||||
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
|
||||
setStreet, setHouseNumber, setPostalCode, setCity,
|
||||
setSoftLevel,
|
||||
setFloor, setFitOut, setParking, setCeilingHeight,
|
||||
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
|
||||
setContactName, setContactEmail, setContactPhone,
|
||||
setImageInput, setFloorPlanUrl, setAiText,
|
||||
addImage, removeImage,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { propertyService } from '../services/propertyService'
|
||||
import { unitService } from '../services/unitService'
|
||||
import type { PropertyUnit } from '../domain/property'
|
||||
import { matchService } from '../services/matchService'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import type { AssetType, ResultType } from '../domain/enums'
|
||||
@@ -13,6 +15,7 @@ interface PropertyFilter {
|
||||
minAreaSqm?: number
|
||||
maxRentPerSqm?: number
|
||||
organizationId?: string
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export function useProperties(filter?: PropertyFilter) {
|
||||
@@ -88,6 +91,18 @@ export function useCreateProperty() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateUnit(propertyId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ unitId, data }: { unitId: string; data: Partial<PropertyUnit> }) =>
|
||||
unitService.update(unitId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['properties'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['property', propertyId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveProperty() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -22,13 +22,13 @@ export function useSchattenmarktSignals(properties: Property[]): FutureSignal[]
|
||||
for (const unit of releasedUnits) {
|
||||
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
|
||||
if (!availableFrom) continue
|
||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths)
|
||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths ?? 0)
|
||||
if (MOCK_TODAY < triggerDate) continue
|
||||
signals.push(buildUnitSignal(p, unit, availableFrom))
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
signals.push(buildPropertySignal(p))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMatches, useMatchesByNeed } from './useMatches'
|
||||
import { useMatchesByNeed } from './useMatches'
|
||||
import { useProperties } from './useProperties'
|
||||
import { useFutureSignals } from './useFutureSignals'
|
||||
import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
||||
import type {
|
||||
UnifiedMatchResult,
|
||||
VerifiedPortfolioResult,
|
||||
FutureAvailabilityResult,
|
||||
} from '../domain/unifiedResult'
|
||||
|
||||
export function useUnifiedResults(needId?: string) {
|
||||
|
||||
@@ -131,3 +131,11 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
|
||||
LOW: 'Niedrig',
|
||||
INCOMPLETE: 'Unvollständig',
|
||||
}
|
||||
|
||||
// Swiss CRB/BKP fit-out investment benchmarks (CHF/m², tenant's cost)
|
||||
export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number }> = {
|
||||
SHELL: { min: 800, max: 1500 },
|
||||
BASIC: { min: 400, max: 800 },
|
||||
FULL: { min: 50, max: 200 },
|
||||
PREMIUM: { min: 0, max: 50 },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
|
||||
|
||||
export interface FitOutInvestment {
|
||||
grossPerSqm: { min: number; max: number }
|
||||
mabOffset: number
|
||||
netPerSqm: { min: number; max: number }
|
||||
netTotal: { min: number; max: number }
|
||||
isFullyCovered: boolean
|
||||
shortLabel: string
|
||||
detailLabel: string
|
||||
}
|
||||
|
||||
function formatChfK(value: number): string {
|
||||
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
|
||||
return `CHF ${Math.round(value / 1000)}k`
|
||||
}
|
||||
|
||||
export function calcFitOutInvestment(
|
||||
fitOut: string,
|
||||
areaSqm: number,
|
||||
mabPerSqm: number,
|
||||
tenantBudgetPerSqm: number,
|
||||
): FitOutInvestment | null {
|
||||
const benchmarks = FIT_OUT_COST_CHF_PER_SQM[fitOut]
|
||||
if (!benchmarks) return null
|
||||
|
||||
const gross = benchmarks
|
||||
const totalOffset = mabPerSqm + tenantBudgetPerSqm
|
||||
const netMin = Math.max(0, gross.min - totalOffset)
|
||||
const netMax = Math.max(0, gross.max - totalOffset)
|
||||
|
||||
const netTotal = {
|
||||
min: Math.round(netMin * areaSqm),
|
||||
max: Math.round(netMax * areaSqm),
|
||||
}
|
||||
|
||||
const isFullyCovered = netTotal.max <= 0
|
||||
|
||||
const shortLabel = isFullyCovered
|
||||
? 'Investition gedeckt'
|
||||
: netTotal.min === netTotal.max
|
||||
? `Est. ${formatChfK(netTotal.min)}`
|
||||
: `Est. ${formatChfK(netTotal.min)}–${formatChfK(netTotal.max)}`
|
||||
|
||||
const detailLabel = isFullyCovered
|
||||
? 'Ausbaukosten durch MAB/Budget gedeckt'
|
||||
: `CHF ${netMin.toLocaleString('de-CH')}–${netMax.toLocaleString('de-CH')}/m² Nettoinvestition`
|
||||
|
||||
return {
|
||||
grossPerSqm: gross,
|
||||
mabOffset: mabPerSqm,
|
||||
netPerSqm: { min: netMin, max: netMax },
|
||||
netTotal,
|
||||
isFullyCovered,
|
||||
shortLabel,
|
||||
detailLabel,
|
||||
}
|
||||
}
|
||||
@@ -145,3 +145,34 @@ export function getMarketRent(city: string, assetType: string): number | null {
|
||||
if (assetType === 'RETAIL') return intel.medianRentRetail
|
||||
return intel.medianRentOffice
|
||||
}
|
||||
|
||||
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
|
||||
|
||||
const CITY_COORDS: Record<string, { lat: number; lng: number }> = {
|
||||
'Zürich': { lat: 47.3769, lng: 8.5417 },
|
||||
'Zürich-West': { lat: 47.3862, lng: 8.5195 },
|
||||
'Zürich-Nord': { lat: 47.4108, lng: 8.5450 },
|
||||
'Basel': { lat: 47.5596, lng: 7.5886 },
|
||||
'Bern': { lat: 46.9480, lng: 7.4474 },
|
||||
'Luzern': { lat: 47.0502, lng: 8.3093 },
|
||||
'Genf': { lat: 46.2044, lng: 6.1432 },
|
||||
'Lausanne': { lat: 46.5197, lng: 6.6323 },
|
||||
'Zug': { lat: 47.1661, lng: 8.5158 },
|
||||
'Winterthur': { lat: 47.5004, lng: 8.7256 },
|
||||
'St. Gallen': { lat: 47.4245, lng: 9.3767 },
|
||||
'Lugano': { lat: 46.0037, lng: 8.9511 },
|
||||
'Baar': { lat: 47.1965, lng: 8.5286 },
|
||||
'Muttenz': { lat: 47.5218, lng: 7.6468 },
|
||||
'Pratteln': { lat: 47.5163, lng: 7.6926 },
|
||||
'Reinach BL': { lat: 47.4948, lng: 7.5946 },
|
||||
'Allschwil': { lat: 47.5529, lng: 7.5368 },
|
||||
'Binningen': { lat: 47.5363, lng: 7.5687 },
|
||||
}
|
||||
|
||||
export function lookupCityCoords(location: string): { lat: number; lng: number } | null {
|
||||
if (CITY_COORDS[location]) return CITY_COORDS[location]
|
||||
// Prefix fallback: "Zürich Kreis 5" → "Zürich"
|
||||
const base = location.split(' ')[0]
|
||||
const key = Object.keys(CITY_COORDS).find(k => k.startsWith(base) || base === k)
|
||||
return key ? CITY_COORDS[key] : null
|
||||
}
|
||||
|
||||
@@ -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.DEMAND_USER,
|
||||
],
|
||||
[WorkspaceType.OPERATIONS]: [
|
||||
UserRole.SUPER_ADMIN,
|
||||
UserRole.ORGANIZATION_ADMIN,
|
||||
],
|
||||
}
|
||||
|
||||
// ── Core Functions ────────────────────────────────────────────────────────────
|
||||
|
||||
+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'],
|
||||
},
|
||||
|
||||
// ── 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',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'DataCloud Systems AG',
|
||||
locationHint: 'Zürich-West / Technopark',
|
||||
title: 'DataCloud Systems AG sucht ~600 m² Bürofläche in Zürich-West',
|
||||
areaSqmEstimate: 600,
|
||||
probability: 0.72,
|
||||
confidenceScore: 0.68,
|
||||
timeHorizonMonths: 10,
|
||||
companyName: 'Alpbach Advisors AG',
|
||||
locationHint: 'Zürich, Innenstadt / Paradeplatz',
|
||||
title: 'Alpbach Advisors AG eröffnet Zürich-Büro — Standort gesucht',
|
||||
areaSqmEstimate: 320,
|
||||
probability: 0.78,
|
||||
confidenceScore: 0.74,
|
||||
timeHorizonMonths: 8,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/datacloud',
|
||||
publishedAt: '2025-04-20',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'PRESS',
|
||||
url: 'https://example.com/news/alpbach-zuerich-expansion',
|
||||
publishedAt: '2025-04-15',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus öffentlichen Quellen. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'Tech-Sektor Stellenwachstum +38% YoY',
|
||||
relevanceScore: 0.76,
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Medienmitteilung und LinkedIn-Daten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.LOW,
|
||||
marketIndicator: 'Wealth-Management Zürich: Neuansiedlungen +14% 2024',
|
||||
relevanceScore: 0.80,
|
||||
isVerified: false,
|
||||
expiresAt: '2026-03-01',
|
||||
expiresAt: '2026-02-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-01T07:00:00Z',
|
||||
updatedAt: '2025-05-10T07: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.',
|
||||
createdAt: '2025-04-18T08:00:00Z',
|
||||
updatedAt: '2025-05-12T09:00:00Z',
|
||||
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: [
|
||||
'14 Stelleninserate mit Standort Zürich-West in 90 Tagen',
|
||||
'Teamwachstum ~40 Personen (DevOps, Cloud-Architektur, Sales)',
|
||||
'Tech-Sektor Zürich-West: +38% Stellenwachstum 2024',
|
||||
'Medienmitteilung vom 15. April: Zürich-Büro offiziell angekündigt',
|
||||
'3 Senior-Partner haben LinkedIn-Standort auf Zürich aktualisiert',
|
||||
'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',
|
||||
signalType: SignalType.EXPANSION,
|
||||
@@ -491,154 +501,186 @@ export const mockFutureSignals: FutureSignal[] = [
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-02T09: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: [
|
||||
'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',
|
||||
'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'],
|
||||
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',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'SwissCart E-Commerce GmbH',
|
||||
locationHint: 'Zug, Industriestrasse',
|
||||
title: 'SwissCart E-Commerce GmbH sucht ~580 m² Logistikfläche in Zug',
|
||||
areaSqmEstimate: 580,
|
||||
probability: 0.66,
|
||||
confidenceScore: 0.62,
|
||||
timeHorizonMonths: 9,
|
||||
companyName: 'EBH Treuhand AG',
|
||||
locationHint: 'Winterthur, Zentrum',
|
||||
title: 'EBH Treuhand AG — Fusion zweier Büros, konsolidierter Standort gesucht',
|
||||
areaSqmEstimate: 450,
|
||||
probability: 0.71,
|
||||
confidenceScore: 0.67,
|
||||
timeHorizonMonths: 7,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/swisscart-zug',
|
||||
publishedAt: '2025-04-18',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'MARKET_DATA',
|
||||
url: 'https://example.com/handelsregister/ebh-fusion',
|
||||
publishedAt: '2025-04-02',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'E-Commerce Zug: Stellenwachstum +55% YoY',
|
||||
relevanceScore: 0.69,
|
||||
disclaimer: 'Signal aus Handelsregistereintrag. Fusion bestätigt — Flächenbedarf abgeleitet. Kein bestätigtes Mietverhältnis.',
|
||||
riskLevel: RiskLevel.LOW,
|
||||
marketIndicator: 'Treuhand-Fusionen Zürich/Winterthur 2024–2025: +18% gegenüber Vorjahr',
|
||||
relevanceScore: 0.74,
|
||||
isVerified: false,
|
||||
expiresAt: '2026-02-01',
|
||||
expiresAt: '2026-01-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-20T07:00:00Z',
|
||||
updatedAt: '2025-05-09T08: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.',
|
||||
createdAt: '2025-04-05T08:00:00Z',
|
||||
updatedAt: '2025-05-10T09:00:00Z',
|
||||
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: [
|
||||
'9 Stelleninserate für Lager- und Logistikpersonal in Zug in 45 Tagen',
|
||||
'E-Commerce-Umsatz CH 2024: +22% — Nachfrage nach Lagerflächen steigt',
|
||||
'Zug Industriestrasse: aktuelle Leerstandsquote Lager unter 3%',
|
||||
'Handelsregistereintrag: Fusion zu EBH Treuhand AG am 2. April 2025 bestätigt',
|
||||
'Kombinierte Teamgrösse ca. 28 Personen — bisherige Flächen je ~200 m² nicht mehr ausreichend',
|
||||
'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',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'Ostschweiz Digital AG',
|
||||
locationHint: 'St. Gallen, Riethüsli',
|
||||
title: 'Ostschweiz Digital AG sucht ~480 m² Bürofläche in St. Gallen',
|
||||
areaSqmEstimate: 480,
|
||||
probability: 0.60,
|
||||
confidenceScore: 0.55,
|
||||
timeHorizonMonths: 10,
|
||||
companyName: 'Centra Schweiz AG',
|
||||
locationHint: 'Zürich, Kreis 4 / 5',
|
||||
title: 'Centra Schweiz AG verlagert Standort von Schlieren in Zürich Innenstadt',
|
||||
areaSqmEstimate: 380,
|
||||
probability: 0.66,
|
||||
confidenceScore: 0.62,
|
||||
timeHorizonMonths: 11,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/ostschweiz-digital',
|
||||
publishedAt: '2025-04-22',
|
||||
type: 'PRESS',
|
||||
url: 'https://example.com/news/centra-standortverlagerung',
|
||||
publishedAt: '2025-04-10',
|
||||
credibility: 'MEDIUM',
|
||||
},
|
||||
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,
|
||||
marketIndicator: 'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024',
|
||||
relevanceScore: 0.62,
|
||||
marketIndicator: 'Zürich Kreis 5: Neuzuzüge Büronutzer +21% 2024',
|
||||
relevanceScore: 0.67,
|
||||
isVerified: false,
|
||||
expiresAt: '2026-03-01',
|
||||
expiresAt: '2026-04-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-25T08:00:00Z',
|
||||
createdAt: '2025-04-12T08: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: [
|
||||
'8 Stelleninserate für Software-Entwickler und UX-Designer in St. Gallen in 60 Tagen',
|
||||
'Ostschweiz Digital AG: Mitarbeiterzahl von 22 auf 31 (+41%) in 9 Monaten',
|
||||
'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024 (IHK-Bericht)',
|
||||
'Medienmitteilung: Verlagerung Schlieren → Zürich Innenstadt offiziell angekündigt',
|
||||
'4 Stelleninserate nennen «Zürich» als Arbeitsort trotz aktuellem Schlieren-Domizil',
|
||||
'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',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'Geneva Finance Partners SA',
|
||||
locationHint: 'Genf, La Praille',
|
||||
title: 'Geneva Finance Partners SA sucht ~520 m² Bürofläche in Genf La Praille',
|
||||
areaSqmEstimate: 520,
|
||||
probability: 0.58,
|
||||
confidenceScore: 0.53,
|
||||
timeHorizonMonths: 20,
|
||||
companyName: 'Rheintaler Logistik Solutions AG',
|
||||
locationHint: 'St. Gallen / Rheintal',
|
||||
title: 'Rheintaler Logistik Solutions AG — Spin-off sucht eigenständigen Standort',
|
||||
areaSqmEstimate: 680,
|
||||
probability: 0.69,
|
||||
confidenceScore: 0.65,
|
||||
timeHorizonMonths: 9,
|
||||
source: {
|
||||
type: 'COMPANY_REPORT',
|
||||
url: 'https://example.com/annual/gfp-2025',
|
||||
publishedAt: '2025-03-05',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'MARKET_DATA',
|
||||
url: 'https://example.com/handelsregister/rheintaler-spinoff',
|
||||
publishedAt: '2025-03-20',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Jahresbericht. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'Büroflächennachfrage Genf: +15% 2025',
|
||||
relevanceScore: 0.60,
|
||||
disclaimer: 'Signal aus Handelsregistereintrag und Pressemitteilung der Muttergesellschaft. Neue juristische Einheit — eigenständiger Standort notwendig.',
|
||||
riskLevel: RiskLevel.LOW,
|
||||
marketIndicator: 'Logistik Ostschweiz: Nachfrage nach kombinierten Büro-/Gewerbeflächen stabil',
|
||||
relevanceScore: 0.72,
|
||||
isVerified: false,
|
||||
expiresAt: '2027-01-01',
|
||||
expiresAt: '2026-02-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-03-08T11:00:00Z',
|
||||
updatedAt: '2025-05-07T10: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.',
|
||||
createdAt: '2025-03-25T09:00:00Z',
|
||||
updatedAt: '2025-05-08T10:00:00Z',
|
||||
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: [
|
||||
'Jahresbericht 2024: «Expansion des Genfer Teams auf 40 FTE bis 2026» angekündigt',
|
||||
'Genf Finanzsektor: +15% Büroflächennachfrage 2025 (CBRE-Bericht)',
|
||||
'5 neue Inserate für Senior-Portfoliomanager mit Standort Genf in 30 Tagen',
|
||||
'Handelsregistereintrag: neue AG «Rheintaler Logistik Solutions AG» per 20. März',
|
||||
'Medienmitteilung: «Vollständige operative Selbständigkeit ab Q3 2025»',
|
||||
'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',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'BernTech Innovation AG',
|
||||
locationHint: 'Bern, Breitenrain',
|
||||
title: 'BernTech Innovation AG sucht ~1\'800 m² Bürofläche in Bern Breitenrain',
|
||||
locationHint: 'Bern, Breitenrain / Lorraine',
|
||||
title: 'BernTech Innovation AG — CHF 12 Mio. Finanzierungsrunde, Standortsuche Bern',
|
||||
areaSqmEstimate: 1800,
|
||||
probability: 0.68,
|
||||
confidenceScore: 0.64,
|
||||
timeHorizonMonths: 16,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/berntech',
|
||||
publishedAt: '2025-04-25',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'PRESS',
|
||||
url: 'https://example.com/news/berntech-finanzierung-2025',
|
||||
publishedAt: '2025-03-18',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
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,
|
||||
marketIndicator: 'Bern Tech-Ökosystem: Risikokapital +40% 2024',
|
||||
relevanceScore: 0.66,
|
||||
@@ -647,14 +689,20 @@ export const mockFutureSignals: FutureSignal[] = [
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-28T09: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: [
|
||||
'18 Stelleninserate in 60 Tagen — Fokus Software-Engineering und Product Management',
|
||||
'BernTech Innovation AG: VC-Finanzierungsrunde CHF 12 Mio. im März 2025 bestätigt',
|
||||
'VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt (Startupticker, Handelszeitung)',
|
||||
'CEO-Zitat: «Neues Büro in Bern» explizit als Verwendungszweck kommuniziert',
|
||||
'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.',
|
||||
confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Region Bern aus Inseraten bestätigt', 'Wachstumstrend seit Q1 2025 sichtbar'],
|
||||
unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung: 1\'500–2\'000 m²)', 'Ob Breitenrain oder andere Berner Lage bevorzugt', 'Zeitpunkt Einzug'],
|
||||
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', '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', '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',
|
||||
},
|
||||
|
||||
// ─── 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',
|
||||
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',
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -30,6 +30,7 @@ export const mockNeeds: Need[] = [
|
||||
prestige: 0.08, accessibility: 0.10, expansionPotential: 0.04, flexibility: 0.05,
|
||||
visibility: 0.02, footfall: 0.01, talentAccess: 0.06, esg: 0.02, taxEnvironment: 0.00,
|
||||
},
|
||||
searchRadius: 30,
|
||||
confidenceInCriteria: 0.88,
|
||||
extractedFromText: 'Wir suchen moderne Büroflächen in Zürich-West, ca. 700–900m², Budget max CHF 42/m², Bezug Herbst 2025.',
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -61,6 +62,10 @@ export const mockNeeds: Need[] = [
|
||||
prestige: 0.02, accessibility: 0.10, expansionPotential: 0.05, flexibility: 0.03,
|
||||
visibility: 0.01, footfall: 0.00, talentAccess: 0.02, esg: 0.02, taxEnvironment: 0.03,
|
||||
},
|
||||
searchRadius: 50,
|
||||
requiresDivisibility: true,
|
||||
minDivisibleUnit: 500,
|
||||
fitOutBudgetMaxPerSqm: 120,
|
||||
confidenceInCriteria: 0.94,
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-03-20T14:00:00Z',
|
||||
@@ -95,6 +100,9 @@ export const mockNeeds: Need[] = [
|
||||
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
||||
visibility: 0.02, footfall: 0.01, talentAccess: 0.08, esg: 0.05, taxEnvironment: 0.00,
|
||||
},
|
||||
searchRadius: 20,
|
||||
isAnonymous: true,
|
||||
fitOutBudgetMaxPerSqm: 250,
|
||||
confidenceInCriteria: 0.91,
|
||||
extractedFromText: 'Suche repräsentative Büroflächen im Raum Basel/Allschwil, 500–800m², max. CHF 38/m², Bezug Q4 2025.',
|
||||
organizationId: 'org-wincasa',
|
||||
@@ -437,6 +445,142 @@ export const mockNeeds: Need[] = [
|
||||
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 ---
|
||||
{
|
||||
id: 'need-uc-c',
|
||||
|
||||
+216
-19
@@ -95,7 +95,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 30,
|
||||
publicTransportMinutes: 12,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 30, loadingDocksCount: 2, ceilingHeightM: 7.5, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 30, loadingDocksCount: 2, ceilingHeightM: 7.5, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
expansionPotentialSqm: 800,
|
||||
contractDurationMonths: 36,
|
||||
@@ -153,6 +153,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 8,
|
||||
publicTransportMinutes: 5,
|
||||
},
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
floorLevel: 2,
|
||||
expansionPotentialSqm: 200,
|
||||
contractDurationMonths: 48,
|
||||
@@ -212,6 +213,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 14,
|
||||
publicTransportMinutes: 8,
|
||||
},
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
floorLevel: 4,
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 4.5,
|
||||
@@ -268,7 +270,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 25,
|
||||
publicTransportMinutes: 14,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 25, loadingDocksCount: 4, ceilingHeightM: 8, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 25, loadingDocksCount: 4, ceilingHeightM: 8, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
expansionPotentialSqm: 600,
|
||||
contractDurationMonths: 60,
|
||||
@@ -380,6 +382,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 40,
|
||||
publicTransportMinutes: 18,
|
||||
},
|
||||
hardFacts: { fitOut: 'SHELL' },
|
||||
floorLevel: 0,
|
||||
expansionPotentialSqm: 1200,
|
||||
contractDurationMonths: 120,
|
||||
@@ -437,6 +440,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 6,
|
||||
publicTransportMinutes: 6,
|
||||
},
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
floorLevel: 5,
|
||||
contractDurationMonths: 36,
|
||||
ancillaryCosts: 6.0,
|
||||
@@ -495,6 +499,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 18,
|
||||
publicTransportMinutes: 7,
|
||||
},
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
floorLevel: 1,
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 5.0,
|
||||
@@ -551,6 +556,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 45,
|
||||
publicTransportMinutes: 16,
|
||||
},
|
||||
hardFacts: { fitOut: 'SHELL' },
|
||||
floorLevel: 0,
|
||||
expansionPotentialSqm: 1500,
|
||||
contractDurationMonths: 60,
|
||||
@@ -608,7 +614,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 56,
|
||||
accessibility: 88,
|
||||
visibilityScore: 72,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 1,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -830,7 +836,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 60,
|
||||
accessibility: 88,
|
||||
visibilityScore: 75,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 70,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -889,7 +895,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 4,
|
||||
publicTransportMinutes: 6,
|
||||
},
|
||||
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 3.2 },
|
||||
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 3.2, mieterausbaubeitragPerSqm: 200 },
|
||||
floorLevel: 1,
|
||||
expansionPotentialSqm: 90,
|
||||
contractDurationMonths: 84,
|
||||
@@ -1048,7 +1054,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 4,
|
||||
publicTransportMinutes: 7,
|
||||
},
|
||||
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 2.7 },
|
||||
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 2.7, mieterausbaubeitragPerSqm: 150 },
|
||||
floorLevel: 1,
|
||||
expansionPotentialSqm: 80,
|
||||
contractDurationMonths: 84,
|
||||
@@ -1112,6 +1118,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-EXT-003',
|
||||
description: 'Attraktive Ladenfläche an der Bahnhofstrasse Bern, direkt beim Hauptbahnhof. EG, sehr hohe Laufkundschaft, maximale Visibilität. Ideal für bekannte Handelsmarken oder Dienstleister mit direktem Kundenkontakt.',
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
units: [
|
||||
{ id: 'unit-003-1', propertyId: 'prop-003', floorLevel: 0, areaSqm: 320, available: true, rentPricePerSqm: 1140 },
|
||||
],
|
||||
@@ -1149,6 +1156,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-EXT-004',
|
||||
description: 'Grosszügige Gewerbefläche an der Europaallee, Zürich Kreis 4, direkt beim Hauptbahnhof. Modernes Gebäude mit flexibler Aufteilung. Hervorragende ÖV-Anbindung und erstklassige Lage. Geeignet als Büro, Showroom oder gemischte Nutzung.',
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
units: [
|
||||
{ id: 'unit-004-1', propertyId: 'prop-004', floorLevel: 2, areaSqm: 1150, available: true, rentPricePerSqm: 624 },
|
||||
],
|
||||
@@ -1193,6 +1201,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'LU-MW-015',
|
||||
description: 'Helle Bürofläche am Kasernenplatz Luzern, im Herzen der Innenstadt. Das 3. Obergeschoss bietet eine grosszügige, flexible Raumaufteilung mit natürlichem Licht von drei Seiten. ÖV-Verbindungen in unmittelbarer Nähe (Bahnhof Luzern, 5 Minuten zu Fuss). Ideal für professionelle Dienstleister.',
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
units: [
|
||||
{ id: 'unit-015-1', propertyId: 'prop-015', floorLevel: 3, areaSqm: 650, available: true, rentPricePerSqm: 456 },
|
||||
],
|
||||
@@ -1236,6 +1245,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BL-MW-016',
|
||||
description: 'Grosszügige Lagerhalle an der Rheinfelderstrasse, direkt im Industriegebiet Muttenz. Ebenerdige Anlieferung mit breitem Tor, 35 Aussenparkplätze. Anbindung A2/A3 unter 5 Minuten. Ideal für Lagerung, Distribution und Leichtindustrie.',
|
||||
hardFacts: { fitOut: 'SHELL' },
|
||||
units: [
|
||||
{ id: 'unit-016-1', propertyId: 'prop-016', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2200, available: true, rentPricePerSqm: 192 },
|
||||
],
|
||||
@@ -1280,6 +1290,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-EXT-017',
|
||||
description: 'Erstklassige Ladenfläche an der Löwenstrasse im Zürcher Hauptbahnhof-Umfeld. EG, direkt an der Fussgängerzone mit sehr hoher Frequenz durch Pendler und Touristen. Schaufensterfront auf zwei Seiten.',
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
units: [
|
||||
{ id: 'unit-017-1', propertyId: 'prop-017', floorLevel: 0, areaSqm: 350, available: true, rentPricePerSqm: 1140 },
|
||||
],
|
||||
@@ -1323,6 +1334,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-EXT-018',
|
||||
description: 'Praktische Bürofläche im 2. Obergeschoss, Breitenrainstrasse Bern. Grosszügige, lichtdurchflutete Räume in ruhigem Quartier nahe dem Stadtzentrum. Bahn- und Busanbindung in 8 Minuten Fussweg. Ideal für Büros, Beratungsfirmen oder Praxen.',
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
units: [
|
||||
{ id: 'unit-018-1', propertyId: 'prop-018', floorLevel: 2, areaSqm: 780, available: true, rentPricePerSqm: 372 },
|
||||
],
|
||||
@@ -1361,6 +1373,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BS-MW-019',
|
||||
description: 'Grosszügige Produktionshalle mit erhöhten Bodenlasten und Dreiphasenstrom im Industriequartier Kleinhüningen Basel. Direkte LKW-Zufahrt über die Voltastrasse. Ideal für Leichtindustrie, Montage oder Lagerhaltung.',
|
||||
hardFacts: { fitOut: 'SHELL' },
|
||||
units: [
|
||||
{ id: 'unit-019-1', propertyId: 'prop-019', floorLevel: 0, unitLabel: 'Produktionshalle', areaSqm: 1900, available: true, rentPricePerSqm: 156 },
|
||||
],
|
||||
@@ -1404,6 +1417,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZG-MW-020',
|
||||
description: 'Grosszügige Bürofläche im 4. Obergeschoss, Industriestrasse Zug. Ruhige Lage mit Panorama-Bergblick, direkter Autobahnanschluss A4. Ideal für Firmen, die von der Zuger Steuerpolitik profitieren möchten, ohne Premium-Innenstadtmieten zu zahlen.',
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
units: [
|
||||
{ id: 'unit-020-1', propertyId: 'prop-020', floorLevel: 4, areaSqm: 820, available: true, rentPricePerSqm: 528 },
|
||||
],
|
||||
@@ -1448,6 +1462,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'GE-MW-021',
|
||||
description: 'Exklusive Verkaufsfläche im Erdgeschoss an der Rue du Rhône, Genf Zentrum. Eine der prestigeträchtigsten Einkaufsstrassen der Schweiz. Ideal für Luxusmarken, Juweliere oder hochwertige Dienstleister mit Anforderung an Prestige und Visibilität.',
|
||||
hardFacts: { fitOut: 'PREMIUM' },
|
||||
units: [
|
||||
{ id: 'unit-021-1', propertyId: 'prop-021', floorLevel: 0, areaSqm: 250, available: true, rentPricePerSqm: 1344 },
|
||||
],
|
||||
@@ -1491,6 +1506,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'SG-EXT-022',
|
||||
description: 'Helle Bürofläche an der Marktgasse im Zentrum St. Gallens. Das 2. Obergeschoss bietet eine zusammenhängende, gut aufteilbare Fläche in historischem Stadtquartier. St. Gallen HB in 6 Minuten zu Fuss. Ideal für Kanzleien, Dienstleister oder regionale Niederlassungen.',
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
units: [
|
||||
{ id: 'unit-022-1', propertyId: 'prop-022', floorLevel: 2, areaSqm: 700, available: true, rentPricePerSqm: 336 },
|
||||
],
|
||||
@@ -1694,6 +1710,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-EXT-031',
|
||||
description: 'Repräsentative Bürofläche im 3. Obergeschoss im modernen Hardturm-Areal Zürich-West. Die zusammenhängende Fläche von 780 m² ist offen gestaltet und kann flexibel unterteilt werden. Grosse Fensterflächen, Klimaanlage und ein Untergeschoss-Parkhaus sind vorhanden. Das Gebäude befindet sich in zentraler Lage mit hervorragender Anbindung an den öffentlichen Verkehr (Tram 4/13, Bahnhof Hardbrücke, 4 Minuten zu Fuss). Übergabe ab Oktober 2025 möglich.',
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
units: [
|
||||
{ id: 'unit-031-1', propertyId: 'prop-031', floorLevel: 3, areaSqm: 780, available: true, rentPricePerSqm: 420 },
|
||||
],
|
||||
@@ -1732,6 +1749,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'ZH-MW-032',
|
||||
description: 'Stilvolles Büroloft im 5. Obergeschoss eines ehemaligen Industriegebäudes an der Pfingstweidstrasse, Kreis 5. Die offene Loftstruktur mit Sichtbetondecken und -wänden schafft ein inspirierendes Arbeitsumfeld. Raumhöhe ca. 3,5 m, Holzböden, individuelle Klimatisierung. Panoramablick über die Dächer Zürichs. Ideal für kreative Unternehmen und Tech-Firmen. Verfügbar ab November 2025.',
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
units: [
|
||||
{ id: 'unit-032-1', propertyId: 'prop-032', floorLevel: 5, areaSqm: 720, available: true, rentPricePerSqm: 480 },
|
||||
],
|
||||
@@ -1770,6 +1788,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-MW-033',
|
||||
description: 'Attraktive Retailfläche an der Marktgasse, direkt in der Berner Fussgängerzone. EG-Fläche mit hoher Laufkundschaft durch Tourismus und Pendler. Tram und Bus direkt vor dem Haus. Ideal für Fashion, Lifestyle oder Gastronomie-Konzepte.',
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
units: [
|
||||
{ id: 'unit-033-1', propertyId: 'prop-033', floorLevel: 0, areaSqm: 260, available: true, rentPricePerSqm: 1440 },
|
||||
],
|
||||
@@ -1808,6 +1827,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BE-EXT-034',
|
||||
description: 'Ansprechende Ladenfläche im trendigen Quartier Lorraine, Bern. EG mit Schaufensterfront, gut positioniert für Kreativwirtschaft und gehobene Kundschaft. Busanbindung in 6 Minuten zum Bahnhof Bern.',
|
||||
hardFacts: { fitOut: 'BASIC' },
|
||||
units: [
|
||||
{ id: 'unit-034-1', propertyId: 'prop-034', floorLevel: 0, areaSqm: 340, available: true, rentPricePerSqm: 1320 },
|
||||
],
|
||||
@@ -1872,6 +1892,7 @@ export const mockProperties: Property[] = [
|
||||
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
|
||||
propertyNumber: 'BS-EXT-036',
|
||||
description: 'Freistehendes Lager-/Logistikgebäude direkt an der Klybeckstrasse, Basel Hafen. Ebenerdige Anlieferung, 38 Aussenparkplätze. Hervorragende Lage für Distribution in der Nordwestschweiz. Kran und Sprinkleranlage vorhanden.',
|
||||
hardFacts: { fitOut: 'SHELL' },
|
||||
units: [
|
||||
{ id: 'unit-036-1', propertyId: 'prop-036', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2600, available: true, rentPricePerSqm: 180 },
|
||||
],
|
||||
@@ -1909,6 +1930,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 0,
|
||||
publicTransportMinutes: 3,
|
||||
},
|
||||
hardFacts: { fitOut: 'FULL' },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 4.0,
|
||||
@@ -2017,7 +2039,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 48,
|
||||
accessibility: 78,
|
||||
visibilityScore: 68,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 60,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2230,7 +2252,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 58,
|
||||
accessibility: 85,
|
||||
visibilityScore: 70,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2373,7 +2395,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 55,
|
||||
accessibility: 82,
|
||||
visibilityScore: 70,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 0,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2486,7 +2508,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 5,
|
||||
publicTransportMinutes: 6,
|
||||
},
|
||||
hardFacts: { floor: 2, parking: 5, fitOut: 'BASIC', ceilingHeightM: 2.8 },
|
||||
hardFacts: { floor: 2, parking: 5, fitOut: 'BASIC', ceilingHeightM: 2.8, mieterausbaubeitragPerSqm: 250 },
|
||||
floorLevel: 2,
|
||||
contractDurationMonths: 84,
|
||||
ancillaryCosts: 3.8,
|
||||
@@ -3453,7 +3475,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 85,
|
||||
accessibility: 95,
|
||||
visibilityScore: 88,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 76,
|
||||
parkingSpots: 0,
|
||||
publicTransportMinutes: 3,
|
||||
@@ -3709,7 +3731,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 30,
|
||||
publicTransportMinutes: 14,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 30, loadingDocksCount: 4, ceilingHeightM: 12.5, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 30, loadingDocksCount: 4, ceilingHeightM: 12.5, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 120,
|
||||
ancillaryCosts: 2.5,
|
||||
@@ -3761,7 +3783,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 35,
|
||||
publicTransportMinutes: 12,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 120,
|
||||
ancillaryCosts: 2.5,
|
||||
@@ -3813,7 +3835,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 40,
|
||||
publicTransportMinutes: 16,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 40, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 40, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 120,
|
||||
ancillaryCosts: 2.2,
|
||||
@@ -3866,7 +3888,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 50,
|
||||
publicTransportMinutes: 18,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 50, loadingDocksCount: 4, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 50, loadingDocksCount: 4, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 2.0,
|
||||
@@ -3913,7 +3935,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 38,
|
||||
publicTransportMinutes: 14,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 38, loadingDocksCount: 2, ceilingHeightM: 11.5, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 38, loadingDocksCount: 2, ceilingHeightM: 11.5, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 2.5,
|
||||
@@ -3958,7 +3980,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 35,
|
||||
publicTransportMinutes: 14,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 120,
|
||||
ancillaryCosts: 2.5,
|
||||
@@ -4266,7 +4288,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 12,
|
||||
publicTransportMinutes: 12,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 12, loadingDocksCount: 1, ceilingHeightM: 5.5, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 12, loadingDocksCount: 1, ceilingHeightM: 5.5, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 2.8,
|
||||
@@ -4318,7 +4340,7 @@ export const mockProperties: Property[] = [
|
||||
parkingSpots: 18,
|
||||
publicTransportMinutes: 15,
|
||||
},
|
||||
hardFacts: { floor: 0, parking: 18, loadingDocksCount: 2, ceilingHeightM: 6.0, isBarrierFree: true },
|
||||
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 18, loadingDocksCount: 2, ceilingHeightM: 6.0, isBarrierFree: true },
|
||||
floorLevel: 0,
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 2.5,
|
||||
@@ -4487,8 +4509,183 @@ export const mockProperties: Property[] = [
|
||||
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> = {}
|
||||
|
||||
@@ -4,6 +4,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-001',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 4.2,
|
||||
activePlatformSearches: 14,
|
||||
avgVacancyMonths: 4.2,
|
||||
demandSupplyRatio: 2.3,
|
||||
comparableListings: 3,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-001-1',
|
||||
@@ -44,6 +52,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-007',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 3.8,
|
||||
activePlatformSearches: 11,
|
||||
avgVacancyMonths: 3.5,
|
||||
demandSupplyRatio: 2.8,
|
||||
comparableListings: 1,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-007-1',
|
||||
@@ -83,6 +99,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-008',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 7.1,
|
||||
activePlatformSearches: 7,
|
||||
avgVacancyMonths: 6.0,
|
||||
demandSupplyRatio: 1.4,
|
||||
comparableListings: 4,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-008-1',
|
||||
@@ -123,6 +147,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-012',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 2.9,
|
||||
activePlatformSearches: 22,
|
||||
avgVacancyMonths: 2.8,
|
||||
demandSupplyRatio: 3.4,
|
||||
comparableListings: 2,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-012-1',
|
||||
@@ -172,6 +204,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-014',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Lager / Logistik',
|
||||
kpis: {
|
||||
vacancyRatePct: 5.5,
|
||||
activePlatformSearches: 11,
|
||||
avgVacancyMonths: 5.8,
|
||||
demandSupplyRatio: 1.9,
|
||||
comparableListings: 2,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-014-1',
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function AISearch() {
|
||||
const [weights, setWeights] = useState<Record<WeightingKey, number>>(defaultWeights)
|
||||
const [weightingKey, setWeightingKey] = useState(0)
|
||||
const [needTitle, setNeedTitle] = useState('')
|
||||
const [isAnonymous, setIsAnonymous] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isManualTextRef = useRef(false)
|
||||
@@ -165,7 +166,7 @@ export default function AISearch() {
|
||||
setStep(NeedBuilderStep.SAVING)
|
||||
const entries = Object.entries(parseResult.confidenceByField)
|
||||
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
|
||||
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE')
|
||||
const input = buildNeedInput({ ...editedCriteria, isAnonymous: isAnonymous || undefined }, weights, needTitle, conf, 'ACTIVE')
|
||||
createNeedMutation.mutate(input, {
|
||||
onSuccess: (created) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||
@@ -253,7 +254,9 @@ export default function AISearch() {
|
||||
needTitle={needTitle}
|
||||
overallConfidence={overallConfidence}
|
||||
isSaving={step === NeedBuilderStep.SAVING}
|
||||
isAnonymous={isAnonymous}
|
||||
onNeedTitleChange={setNeedTitle}
|
||||
onAnonymousChange={setIsAnonymous}
|
||||
onBack={() => setStep(NeedBuilderStep.IDLE)}
|
||||
onSave={handleSaveProfile}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'luci
|
||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { InquiryMessage } from '../../domain/inquiry'
|
||||
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
||||
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||
@@ -32,7 +31,7 @@ export default function Anfragen() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { data: pipelineItems = [] } = usePipelineItems()
|
||||
const { mutate: moveStage } = useMoveStage()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
|
||||
const preselectedId = searchParams.get('inquiry')
|
||||
|
||||
@@ -156,7 +155,7 @@ export default function Anfragen() {
|
||||
<TextField
|
||||
size="small" placeholder="Suchen..." fullWidth
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> }}
|
||||
slotProps={{ input: { startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> } }}
|
||||
sx={{ mb: 1.25 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -41,7 +41,6 @@ export default function Compare() {
|
||||
overallWinnerIdx,
|
||||
bestScoreIdx,
|
||||
worstConfIdx,
|
||||
dqScores,
|
||||
worstDQIdx,
|
||||
missingCriticalCounts,
|
||||
maxMissingCritical,
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeft } from 'lucide-react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||
import { PropertyMap } from '../../components/shared'
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import {
|
||||
LocationIntelligencePanel,
|
||||
ExecutiveSummaryPanel,
|
||||
NeedAlignmentPanel,
|
||||
ScoreBreakdownPanel,
|
||||
TradeoffPanel,
|
||||
RiskPanel,
|
||||
MissingInformationPanel,
|
||||
FutureAvailabilityContextPanel,
|
||||
NextActionsPanel,
|
||||
} from '../../components/match-detail'
|
||||
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
|
||||
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
|
||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
|
||||
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections'
|
||||
import { MatchDetailScoreBreakdown } from '../../components/match-detail/MatchDetailScoreBreakdown'
|
||||
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -49,7 +37,6 @@ export default function MatchDetail() {
|
||||
const { addToCompare } = useCompareStore()
|
||||
const { openSavedDialog } = usePipelineStore()
|
||||
const { openInquiryDialog } = useInquiryStore()
|
||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||
|
||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||
|
||||
@@ -85,13 +72,16 @@ export default function MatchDetail() {
|
||||
const cityIntel = city ? getCityIntelligence(city) : null
|
||||
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
|
||||
|
||||
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
|
||||
const searchLabel = need?.preferredLocations?.[0]
|
||||
|
||||
const topTradeoff = match.tradeoffs?.[0] ?? match.tradeOffs?.[0]
|
||||
const summary = match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten.`
|
||||
|
||||
const handleCompare = () => {
|
||||
if (match && !isFuture && property) {
|
||||
addToCompare({
|
||||
resultType: property.resultType ?? 'VERIFIED_PORTFOLIO',
|
||||
resultType: (property.resultType ?? 'VERIFIED_PORTFOLIO') as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
|
||||
})
|
||||
} else if (match && isFuture && signal) {
|
||||
@@ -124,6 +114,15 @@ export default function MatchDetail() {
|
||||
{ label: 'Miete/m²/Jahr', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}` : '–' },
|
||||
{ label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
|
||||
{ label: 'Nutzungsart', value: property?.assetType ?? '–' },
|
||||
...(property?.hardFacts?.fitOut ? [{
|
||||
label: 'Ausbaustandard',
|
||||
value: (() => {
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
|
||||
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
|
||||
return mab ? `${base} + CHF ${mab} MAB` : base
|
||||
})(),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -165,6 +164,10 @@ export default function MatchDetail() {
|
||||
keyFacts={keyFacts}
|
||||
onCompare={handleCompare}
|
||||
onShortlist={handleShortlist}
|
||||
searchCenterLat={searchCenter?.lat}
|
||||
searchCenterLng={searchCenter?.lng}
|
||||
searchRadiusKm={need?.searchRadius}
|
||||
searchLabel={searchLabel}
|
||||
/>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
@@ -210,51 +213,26 @@ export default function MatchDetail() {
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* ── 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}
|
||||
{/* ── Section C: Reale Jahresbelastung (alle fitOut-Werte) ── */}
|
||||
{!isFuture && property?.hardFacts?.fitOut && (
|
||||
<FitOutCostPanel
|
||||
fitOut={property.hardFacts.fitOut}
|
||||
areaSqm={property.areaSqm}
|
||||
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
|
||||
rentPricePerSqm={property.rentPricePerSqm}
|
||||
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
|
||||
/>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Full analysis toggle + expanded panels ── */}
|
||||
<MatchDetailScoreBreakdown
|
||||
match={match}
|
||||
property={property}
|
||||
need={need}
|
||||
signal={signal}
|
||||
isFuture={isFuture}
|
||||
taxCalculatorUrl={taxCalculatorUrl}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { Box, Button, Card, Typography } from '@mui/material'
|
||||
import { Box, Button, Card, Chip, Typography } from '@mui/material'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
@@ -161,6 +161,29 @@ export default function Results() {
|
||||
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.searchRadius && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Radius:</strong> {activeNeed.searchRadius} km
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.requiredFitOut && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Ausbaustandard:</strong> min. {
|
||||
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
|
||||
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
|
||||
}
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.fitOutBudgetMaxPerSqm && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
|
||||
</Typography>
|
||||
)}
|
||||
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
|
||||
@@ -168,6 +191,14 @@ export default function Results() {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
{activeNeed.isAnonymous && (
|
||||
<Chip
|
||||
label="Anonyme Suche"
|
||||
size="small"
|
||||
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
@@ -177,6 +208,7 @@ export default function Results() {
|
||||
Suche ändern
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
+361
-179
@@ -1,231 +1,413 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
Drawer,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
|
||||
import { AlertTriangle, CheckCircle2, Clock, Edit2, RefreshCw } from 'lucide-react'
|
||||
import { LoadingPage, ErrorState } from '../../components/ui'
|
||||
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
|
||||
import { PropertyDetailView } from '../../components/supply'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { getRecommendedActions } from '../../services/dataQualityService'
|
||||
import { DataFreshness } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE'
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 0.8) return 'success'
|
||||
if (score >= 0.6) return 'warning'
|
||||
return 'error'
|
||||
type QualityFilter = 'ALL' | 'INCOMPLETE' | 'STALE' | 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const FIELD_LABEL: Record<string, string> = {
|
||||
'Mietpreis/m²': 'Mietpreis',
|
||||
'Fläche m²': 'Fläche',
|
||||
'Verfügbarkeit': 'Verfügbarkeit',
|
||||
'Adresse': 'Adresse',
|
||||
'Beschreibung': 'Beschreibung',
|
||||
'Bilder': 'Bilder',
|
||||
'Ausbaustandard': 'Ausbaustandard',
|
||||
'Soft Factors': 'Soft Factors',
|
||||
'Jahresmiete (CHF)': 'Jahresmiete',
|
||||
'Expansionspotenzial': 'Erweiterung',
|
||||
}
|
||||
|
||||
function priorityOf(p: Property): number {
|
||||
const hasCritical = p.dataQuality.missingCriticalFields.length > 0
|
||||
const isStale = p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
|
||||
if (hasCritical && isStale) return 0
|
||||
if (hasCritical) return 1
|
||||
if (isStale) return 2
|
||||
if (p.dataQuality.score < 0.6) return 3
|
||||
if (p.dataQuality.score < 0.8) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
// ── Row ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PropertyRow = memo(function PropertyRow({
|
||||
property,
|
||||
onEdit,
|
||||
}: {
|
||||
property: Property
|
||||
onEdit: (id: string) => void
|
||||
}) {
|
||||
const q = property.dataQuality
|
||||
const hasCritical = q.missingCriticalFields.length > 0
|
||||
const isStale = q.freshness === DataFreshness.STALE || q.freshness === DataFreshness.OUTDATED
|
||||
const topAction = getRecommendedActions(q, q.freshness)[0] ?? null
|
||||
|
||||
const visibleFields = q.missingCriticalFields.slice(0, 3)
|
||||
const overflow = q.missingCriticalFields.length - visibleFields.length
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onEdit(property.id)}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
|
||||
alignItems: 'center',
|
||||
px: 2, py: 1.25,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
cursor: 'pointer',
|
||||
bgcolor: hasCritical ? '#fff8f8' : 'white',
|
||||
'&:hover': { bgcolor: hasCritical ? '#fff0f0' : '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Objekt */}
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{property.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{property.location.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Score */}
|
||||
<Box>
|
||||
<DataQualityBadge quality={q} showLabel />
|
||||
</Box>
|
||||
|
||||
{/* Fehlende Pflichtfelder */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.4 }}>
|
||||
{hasCritical ? (
|
||||
<>
|
||||
{visibleFields.map(f => (
|
||||
<Chip
|
||||
key={f}
|
||||
label={FIELD_LABEL[f] ?? f}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626', fontWeight: 600 }}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Chip
|
||||
label={`+${overflow}`}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Chip
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
label="Vollständig"
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#dcfce7', color: '#16a34a' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Aktualität */}
|
||||
<Box>
|
||||
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
|
||||
</Box>
|
||||
|
||||
{/* Empfehlung */}
|
||||
<Box>
|
||||
{topAction ? (
|
||||
<Tooltip title={topAction.detail}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{topAction.priority === 'HIGH'
|
||||
? <AlertTriangle size={12} color="#dc2626" />
|
||||
: isStale
|
||||
? <Clock size={12} color="#d97706" />
|
||||
: <RefreshCw size={12} color="#64748b" />
|
||||
}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: topAction.priority === 'HIGH' ? '#dc2626' : '#d97706',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.68rem',
|
||||
lineHeight: 1.2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{topAction.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem' }}>—</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Edit button */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Objekt bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEdit(property.id)}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#1e3a5f' } }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ── KPI Card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({
|
||||
label, value, sub, color, active, onClick,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
sub?: string
|
||||
color: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
cursor: 'pointer',
|
||||
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
|
||||
bgcolor: active ? `${color}08` : 'white',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': { borderColor: color, bgcolor: `${color}08` },
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color, lineHeight: 1.2, my: 0.5 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DataQuality() {
|
||||
const [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
|
||||
const [filter, setFilter] = useState<QualityFilter>('ALL')
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
|
||||
const { data: properties = [], isLoading, error } = useProperties()
|
||||
|
||||
if (isLoading) return <LoadingPage />
|
||||
if (error) return <ErrorState />
|
||||
|
||||
const avgScore = properties.length
|
||||
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
|
||||
const total = properties.length
|
||||
|
||||
const avgScore = total
|
||||
? properties.reduce((s, p) => s + p.dataQuality.score, 0) / total
|
||||
: 0
|
||||
|
||||
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
||||
const staleData = properties.filter(
|
||||
const incomplete = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
||||
const stale = properties.filter(
|
||||
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED,
|
||||
)
|
||||
const highQuality = properties.filter(p => p.dataQuality.score >= 0.8)
|
||||
const medQuality = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
|
||||
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6)
|
||||
const high = properties.filter(p => p.dataQuality.score >= 0.8)
|
||||
const medium = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
|
||||
const low = properties.filter(p => p.dataQuality.score < 0.6)
|
||||
|
||||
const filtered = [...properties]
|
||||
.filter(p => {
|
||||
if (!qualityFilter) return true
|
||||
if (qualityFilter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
|
||||
if (qualityFilter === 'HIGH') return p.dataQuality.score >= 0.8 && p.dataQuality.missingCriticalFields.length === 0
|
||||
if (qualityFilter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
|
||||
if (qualityFilter === 'LOW') return p.dataQuality.score < 0.6
|
||||
const filtered = useMemo(() => {
|
||||
const base = [...properties]
|
||||
const result = base.filter(p => {
|
||||
if (filter === 'ALL') return true
|
||||
if (filter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
|
||||
if (filter === 'STALE') return p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
|
||||
if (filter === 'HIGH') return p.dataQuality.score >= 0.8
|
||||
if (filter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
|
||||
if (filter === 'LOW') return p.dataQuality.score < 0.6
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.dataQuality.score - b.dataQuality.score)
|
||||
return result.sort((a, b) => priorityOf(a) - priorityOf(b))
|
||||
}, [properties, filter])
|
||||
|
||||
const scoreColor = avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#dc2626'
|
||||
|
||||
function toggleFilter(f: QualityFilter) {
|
||||
setFilter(prev => prev === f ? 'ALL' : f)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Page Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenpflege</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<Box className="grid grid-cols-3 gap-4">
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Ø Qualitätsscore</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}>
|
||||
{Math.round(avgScore * 100)}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)}
|
||||
sx={{ height: 8, borderRadius: 4, mt: 1 }} />
|
||||
</Card>
|
||||
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Pflichtfelder fehlen</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}>
|
||||
{criticalIssues.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}>
|
||||
{staleData.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Quality Distribution */}
|
||||
<SectionContainer title="Qualitätsverteilung">
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Box className="flex flex-col gap-3">
|
||||
{[
|
||||
{ label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const },
|
||||
{ label: 'Mittel (60–79%)', count: medQuality.length, color: 'warning' as const },
|
||||
{ label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
|
||||
].map(row => (
|
||||
<Box key={row.label} className="flex items-center gap-3">
|
||||
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>{row.label}</Typography>
|
||||
<Chip label={row.count} size="small" color={row.color} />
|
||||
<Box className="flex-1">
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={properties.length > 0 ? (row.count / properties.length) * 100 : 0}
|
||||
color={row.color}
|
||||
sx={{ height: 10, borderRadius: 5 }}
|
||||
{/* KPI cards */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2 }}>
|
||||
<KpiCard
|
||||
label="Ø Qualitätsscore"
|
||||
value={`${Math.round(avgScore * 100)}%`}
|
||||
color={scoreColor}
|
||||
active={filter === 'ALL'}
|
||||
onClick={() => setFilter('ALL')}
|
||||
sub={`${total} Objekte insgesamt`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Pflichtfelder fehlen"
|
||||
value={incomplete.length}
|
||||
sub={`von ${total} Objekten — klicken zum Filtern`}
|
||||
color="#dc2626"
|
||||
active={filter === 'INCOMPLETE'}
|
||||
onClick={() => toggleFilter('INCOMPLETE')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Veraltete Daten"
|
||||
value={stale.length}
|
||||
sub={`von ${total} Objekten — klicken zum Filtern`}
|
||||
color="#d97706"
|
||||
active={filter === 'STALE'}
|
||||
onClick={() => toggleFilter('STALE')}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
||||
{properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}%
|
||||
|
||||
{/* Quality distribution */}
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Qualitätsverteilung</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{[
|
||||
{ label: 'Hoch (≥80%)', count: high.length, color: '#16a34a', f: 'HIGH' as QualityFilter },
|
||||
{ label: 'Mittel (60–79%)', count: medium.length, color: '#d97706', f: 'MEDIUM' as QualityFilter },
|
||||
{ label: 'Niedrig (<60%)', count: low.length, color: '#dc2626', f: 'LOW' as QualityFilter },
|
||||
].map(row => (
|
||||
<Box
|
||||
key={row.label}
|
||||
onClick={() => toggleFilter(row.f)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 2, cursor: 'pointer',
|
||||
p: 0.75, borderRadius: 1,
|
||||
bgcolor: filter === row.f ? `${row.color}10` : 'transparent',
|
||||
'&:hover': { bgcolor: `${row.color}08` },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ width: 130, flexShrink: 0, fontWeight: filter === row.f ? 700 : 400 }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={row.count}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem', fontWeight: 700, bgcolor: `${row.color}18`, color: row.color, flexShrink: 0 }}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={total > 0 ? (row.count / total) * 100 : 0}
|
||||
sx={{
|
||||
height: 10, borderRadius: 5,
|
||||
bgcolor: `${row.color}18`,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: row.color, borderRadius: 5 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', width: 36, textAlign: 'right', flexShrink: 0 }}>
|
||||
{total > 0 ? Math.round((row.count / total) * 100) : 0}%
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</SectionContainer>
|
||||
|
||||
{/* Objects Table */}
|
||||
<SectionContainer title="Objektübersicht">
|
||||
{/* Filter bar */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={qualityFilter}
|
||||
onChange={e => setQualityFilter(e.target.value as QualityFilter)}
|
||||
displayEmpty
|
||||
sx={{ fontSize: '0.8125rem', minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">Alle Qualitätsstufen</MenuItem>
|
||||
<MenuItem value="HIGH">Hoch (≥80%)</MenuItem>
|
||||
<MenuItem value="MEDIUM">Mittel (60–79%)</MenuItem>
|
||||
<MenuItem value="LOW">Niedrig ({'<'}60%)</MenuItem>
|
||||
<MenuItem value="INCOMPLETE">Pflichtfelder fehlen</MenuItem>
|
||||
</Select>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filtered.length} von {properties.length} Objekten
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Qualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Pflichtfelder</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Nächste Massnahme</Typography></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map(property => {
|
||||
const q = property.dataQuality
|
||||
const hasCritical = q.missingCriticalFields.length > 0
|
||||
const actions = getRecommendedActions(q, q.freshness)
|
||||
const topAction = actions[0] ?? null
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={property.id}
|
||||
hover
|
||||
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.03)' } : {}}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.25 }}>{property.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{property.location.city}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DataQualityBadge quality={q} showLabel />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{q.missingCriticalFields.length === 0 ? (
|
||||
<Chip label="Vollständig" color="success" size="small" />
|
||||
) : (
|
||||
<Tooltip
|
||||
title={q.missingCriticalFields.join(', ')}
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
label={`${q.missingCriticalFields.length} fehlen`}
|
||||
color="error"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{topAction ? (
|
||||
{/* Object list */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: topAction.priority === 'HIGH' ? '#c0392b' : '#d97706' }}>
|
||||
{topAction.label}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, flex: 1 }}>Objektübersicht</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filtered.length} von {total} Objekten · sortiert nach Handlungsbedarf
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
{topAction.detail}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">—</Typography>
|
||||
{filter !== 'ALL' && (
|
||||
<Chip
|
||||
label={`Filter: ${filter}`}
|
||||
size="small"
|
||||
onDelete={() => setFilter('ALL')}
|
||||
sx={{ height: 22, fontSize: '0.68rem' }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<Card sx={{ overflow: 'hidden' }}>
|
||||
{/* Table header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
|
||||
px: 2, py: 1,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{['Objekt', 'Qualität', 'Fehlende Pflichtfelder', 'Aktualität', 'Empfehlung', ''].map(h => (
|
||||
<Typography key={h} variant="caption" sx={{ fontWeight: 700, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||||
{h}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{filtered.map(p => (
|
||||
<PropertyRow key={p.id} property={p} onEdit={setDetailId} />
|
||||
))}
|
||||
</Card>
|
||||
</SectionContainer>
|
||||
) : (
|
||||
<Alert severity="success" sx={{ mt: 1 }}>
|
||||
Keine Objekte in dieser Kategorie — alles in Ordnung!
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Edit drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
|
||||
>
|
||||
{detailId && (
|
||||
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} />
|
||||
)}
|
||||
</Drawer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Divider,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart2,
|
||||
Briefcase,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Newspaper,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useMarketLeads } from '../../hooks/useMarketLeads'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||
import { DecisionContextPanel } from '../../components/ui'
|
||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
// ── Source meta ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={12} /> },
|
||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={12} /> },
|
||||
COMPANY_REPORT: { label: 'Geschäftsbericht',icon: <FileText size={12} /> },
|
||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={12} /> },
|
||||
}
|
||||
|
||||
const QUALITY_META: Record<string, { label: string; color: string }> = {
|
||||
HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success },
|
||||
MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning },
|
||||
LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error },
|
||||
}
|
||||
|
||||
function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' {
|
||||
return probability >= 0.70 ? 'HIGH' : probability >= 0.50 ? 'MEDIUM' : 'LOW'
|
||||
}
|
||||
|
||||
// ── Property match chip ───────────────────────────────────────────────────────
|
||||
|
||||
function PropertyMatchRow({ p }: { p: Property }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Box
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 1.25, py: 0.75, borderRadius: 1,
|
||||
bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`,
|
||||
cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg },
|
||||
}}
|
||||
>
|
||||
<Building2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.success, flexShrink: 0 }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Lead card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function LeadCard({ lead }: { lead: MarketLead }) {
|
||||
const { signal, matchingProperties } = lead
|
||||
const probPct = Math.round(signal.probability * 100)
|
||||
const qual = signalQuality(signal.probability)
|
||||
const qualMeta = QUALITY_META[qual]
|
||||
const sourceMeta = SOURCE_META[signal.source.type] ?? null
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5, mb: 2,
|
||||
borderLeft: `3px solid ${DS_MARKET_SIGNAL.accent}`,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
|
||||
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
|
||||
<Target size={18} color={DS_MARKET_SIGNAL.accent} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25, mb: 0.25 }}>
|
||||
{signal.companyName ?? signal.locationHint}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{signal.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Chip
|
||||
label="Market Signal"
|
||||
size="small"
|
||||
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700, fontSize: '0.68rem', flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Key facts strip */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1.75 }}>
|
||||
{signal.areaSqmEstimate && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Flächenbedarf</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Zeithorizont</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: qualMeta.color }}>{probPct}%</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Standort</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{signal.locationHint}</Typography>
|
||||
</Box>
|
||||
{sourceMeta && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Quelle</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{sourceMeta.icon}
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.75 }} />
|
||||
|
||||
{/* Market indicators */}
|
||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
||||
<Box sx={{ mb: 1.75 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||
Erkannte Nachfragesignale
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{signal.marketIndicators.map((ind, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<TrendingUp size={11} color={DS_MARKET_SIGNAL.accent} style={{ marginTop: 3, flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{ind}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confirmed / unconfirmed facts */}
|
||||
{((signal.confirmedFacts?.length ?? 0) > 0 || (signal.unconfirmedFacts?.length ?? 0) > 0) && (
|
||||
<Box sx={{ mb: 1.75 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||
{(signal.confirmedFacts ?? []).map((f, i) => (
|
||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.success, fontWeight: 500 }}>{f}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{(signal.unconfirmedFacts ?? []).map((f, i) => (
|
||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<AlertCircle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.warning, fontWeight: 500 }}>{f}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
{/* Portfolio matches */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||
Passende Objekte im Portfolio ({matchingProperties.length})
|
||||
</Typography>
|
||||
{matchingProperties.length === 0 ? (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic' }}>
|
||||
Kein passendes Portfolioobjekt gefunden — manuell prüfen empfohlen
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{matchingProperties.slice(0, 3).map(p => (
|
||||
<PropertyMatchRow key={p.id} p={p} />
|
||||
))}
|
||||
{matchingProperties.length > 3 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}>
|
||||
+{matchingProperties.length - 3} weitere Objekte
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BG.subtle}`, lineHeight: 1.4 }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Loading skeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
function LeadSkeleton() {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2, borderLeft: `3px solid ${DS_BORDER.default}` }}>
|
||||
<Skeleton variant="text" width="60%" height={24} sx={{ mb: 0.5 }} />
|
||||
<Skeleton variant="text" width="80%" height={18} sx={{ mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1, mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={64} sx={{ borderRadius: 1 }} />
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MarketLeads() {
|
||||
const { data: leads, isLoading } = useMarketLeads()
|
||||
|
||||
const leadsWithMatch = leads.filter(l => l.matchingProperties.length > 0)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Users size={20} color={DS_MARKET_SIGNAL.accent} />
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Markt-Leads</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Erkannte Unternehmen mit aktivem Flächenbedarf — potenzielle Mieter für Ihr Portfolio
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isLoading && (
|
||||
<Chip
|
||||
label={`${leads.length} aktive Leads`}
|
||||
size="small"
|
||||
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
||||
|
||||
{/* Decision context */}
|
||||
{!isLoading && leads.length > 0 && (
|
||||
<DecisionContextPanel
|
||||
decision="Welche Unternehmen könnten als Nachmieter für Ihre Objekte in Frage kommen?"
|
||||
context={`${leads.length} erkannte Nachfragesignale · ${leadsWithMatch.length} mit passendem Portfolioobjekt`}
|
||||
metrics={[
|
||||
{ label: 'aktive Markt-Leads', value: leads.length, severity: 'positive' },
|
||||
...(leadsWithMatch.length > 0
|
||||
? [{ label: 'mit passendem Objekt', value: leadsWithMatch.length, severity: 'positive' as const }]
|
||||
: []),
|
||||
]}
|
||||
risks={leads.length - leadsWithMatch.length > 0
|
||||
? [`${leads.length - leadsWithMatch.length} Leads ohne direkten Portfolio-Match — Leerstandsanalyse empfohlen`]
|
||||
: []
|
||||
}
|
||||
actions={[]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LeadSkeleton />
|
||||
<LeadSkeleton />
|
||||
<LeadSkeleton />
|
||||
</>
|
||||
) : leads.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Target size={32} color={DS_TEXT.disabled} style={{ marginBottom: 12 }} />
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>Keine aktiven Markt-Leads gefunden.</Typography>
|
||||
<Typography variant="caption" color="text.disabled">Signale werden laufend aus öffentlichen Quellen erkannt.</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
leads.map(lead => <LeadCard key={lead.signal.id} lead={lead} />)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+337
-89
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Alert,
|
||||
@@ -10,14 +10,21 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
|
||||
import { useMatchesByProperty } from '../../hooks/useMatches'
|
||||
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||
import { PropertyDetailView } from '../../components/supply'
|
||||
import { PublicNeedDetail, OfferWizard } from '../../components/anfragencenter'
|
||||
import { NEED_TO_LATENT_NEED } from '../../lib/needToLatentNeedMap'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Match } from '../../domain/match'
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro',
|
||||
@@ -28,11 +35,276 @@ const ASSET_LABELS: Record<string, string> = {
|
||||
MIXED: 'Gemischt',
|
||||
}
|
||||
|
||||
const GRID_COLS = '2fr 1fr 80px 100px 110px 120px 80px 48px'
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return '–'
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function matchColor(score: number): string {
|
||||
if (score >= 80) return '#1a7a4a'
|
||||
if (score >= 60) return '#d97706'
|
||||
return '#dc2626'
|
||||
}
|
||||
|
||||
// ─── Suchabo dialog ────────────────────────────────────────────────────────
|
||||
|
||||
function MatchListItem({
|
||||
match,
|
||||
onSelect,
|
||||
}: {
|
||||
match: Match
|
||||
onSelect: (needId: string) => void
|
||||
}) {
|
||||
const latentNeedId = NEED_TO_LATENT_NEED[match.needId] ?? null
|
||||
const { data: latentNeed } = useLatentNeedById(latentNeedId)
|
||||
const companyName = latentNeed?.tenantCompany ?? match.needId
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2, py: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'white',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{companyName}
|
||||
</Typography>
|
||||
{latentNeed && (
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{latentNeed.desiredLocation}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Chip
|
||||
label={`${match.matchScore}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
bgcolor: `${matchColor(match.matchScore)}18`,
|
||||
color: matchColor(match.matchScore),
|
||||
}}
|
||||
/>
|
||||
{latentNeedId ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => onSelect(match.needId)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
}}
|
||||
>
|
||||
Anschreiben
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip title="Kein öffentliches Suchabo verknüpft">
|
||||
<span>
|
||||
<Button size="small" disabled sx={{ textTransform: 'none', fontSize: '0.75rem' }}>
|
||||
Anschreiben
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface SuchaboDialogProps {
|
||||
propertyId: string
|
||||
matches: Match[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function SuchaboDialogContent({ matches }: SuchaboDialogProps) {
|
||||
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(
|
||||
matches.length === 1 ? matches[0].needId : null,
|
||||
)
|
||||
const latentNeedId = selectedNeedId ? (NEED_TO_LATENT_NEED[selectedNeedId] ?? null) : null
|
||||
const { data: latentNeed, isLoading } = useLatentNeedById(latentNeedId)
|
||||
|
||||
const showList = !selectedNeedId
|
||||
|
||||
if (showList) {
|
||||
return (
|
||||
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{matches.map(m => (
|
||||
<MatchListItem key={m.id} match={m} onSelect={setSelectedNeedId} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!latentNeed) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">Suchabo nicht gefunden.</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{matches.length > 1 && (
|
||||
<Box sx={{ px: 2, pt: 1.5 }}>
|
||||
<Button
|
||||
startIcon={<ArrowLeft size={14} />}
|
||||
size="small"
|
||||
onClick={() => setSelectedNeedId(null)}
|
||||
sx={{ textTransform: 'none', color: '#64748b', fontWeight: 500 }}
|
||||
>
|
||||
Alle Suchabos
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<PublicNeedDetail need={latentNeed} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Row ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RowProps {
|
||||
p: Property
|
||||
isLast: boolean
|
||||
onDetail: (id: string) => void
|
||||
onToggleStatus: (p: Property) => void
|
||||
onDelete: (p: Property) => void
|
||||
onChipClick: (propertyId: string, matches: Match[]) => void
|
||||
updatePending: boolean
|
||||
removePending: boolean
|
||||
}
|
||||
|
||||
const ListingRow = memo(function ListingRow({
|
||||
p, isLast, onDetail, onToggleStatus, onDelete, onChipClick, updatePending, removePending,
|
||||
}: RowProps) {
|
||||
const { data: matches = [] } = useMatchesByProperty(p.id)
|
||||
const topScore = matches.length > 0 ? Math.max(...matches.map(m => m.matchScore)) : 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onDetail(p.id)}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
px: 2, py: 1.25,
|
||||
alignItems: 'center',
|
||||
borderBottom: isLast ? 'none' : '1px solid #f1f5f9',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Title + type */}
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={ASSET_LABELS[p.assetType] ?? p.assetType}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', mt: 0.25, bgcolor: '#f1f5f9', color: '#475569' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* City */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.location.city}
|
||||
</Typography>
|
||||
|
||||
{/* Area */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
{/* Rent */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
|
||||
</Typography>
|
||||
|
||||
{/* Match count */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
{matches.length > 0 ? (
|
||||
<Tooltip title="Suchabos anzeigen und anschreiben">
|
||||
<Chip
|
||||
label={`${matches.length} Suchabo${matches.length !== 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
onClick={() => onChipClick(p.id, matches)}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: `${matchColor(topScore)}18`,
|
||||
color: matchColor(topScore),
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>—</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Created */}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{formatDate(p.createdAt)}
|
||||
</Typography>
|
||||
|
||||
{/* Status toggle */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.status === 'ACTIVE'}
|
||||
onChange={() => onToggleStatus(p)}
|
||||
disabled={updatePending}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Delete */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Inserat löschen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDelete(p)}
|
||||
disabled={removePending}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MyListings() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -40,26 +312,25 @@ export default function MyListings() {
|
||||
const updateProperty = useUpdateProperty()
|
||||
const removeProperty = useRemoveProperty()
|
||||
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [suchaboDialog, setSuchaboDialog] = useState<{
|
||||
propertyId: string
|
||||
matches: Match[]
|
||||
} | null>(null)
|
||||
|
||||
function handleToggleStatus(p: Property) {
|
||||
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
updateProperty.mutate(
|
||||
{ id: p.id, input: { status: next } },
|
||||
{
|
||||
onError: () => {
|
||||
setActionError('Status konnte nicht geändert werden.')
|
||||
},
|
||||
},
|
||||
{ onError: () => setActionError('Status konnte nicht geändert werden.') },
|
||||
)
|
||||
}
|
||||
|
||||
function handleDelete(p: Property) {
|
||||
removeProperty.mutate(p.id, {
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null)
|
||||
},
|
||||
onSuccess: () => setConfirmDelete(null),
|
||||
onError: () => {
|
||||
setActionError('Inserat konnte nicht gelöscht werden.')
|
||||
setConfirmDelete(null)
|
||||
@@ -68,10 +339,10 @@ export default function MyListings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 960, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ maxWidth: 1040, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Meine Inserate</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Direkt erstellte Inserate — unabhängig vom Portfolio
|
||||
</Typography>
|
||||
@@ -116,98 +387,72 @@ export default function MyListings() {
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
px: 2, py: 1,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}>
|
||||
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Erstellt', 'Aktiv', ''].map(h => (
|
||||
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Suchabos', 'Erstellt', 'Aktiv', ''].map(h => (
|
||||
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||||
{h}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{listings.map((p, i) => {
|
||||
const isLast = i === listings.length - 1
|
||||
return (
|
||||
<Box
|
||||
{listings.map((p, i) => (
|
||||
<ListingRow
|
||||
key={p.id}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
||||
px: 2, py: 1.25,
|
||||
alignItems: 'center',
|
||||
borderBottom: isLast ? 'none' : '1px solid #f1f5f9',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Title + type */}
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={ASSET_LABELS[p.assetType] ?? p.assetType}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', mt: 0.25, bgcolor: '#f1f5f9', color: '#475569' }}
|
||||
p={p}
|
||||
isLast={i === listings.length - 1}
|
||||
onDetail={setDetailId}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={setConfirmDelete}
|
||||
onChipClick={(pid, m) => setSuchaboDialog({ propertyId: pid, matches: m })}
|
||||
updatePending={updateProperty.isPending}
|
||||
removePending={removeProperty.isPending}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* City */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.location.city}
|
||||
</Typography>
|
||||
|
||||
{/* Area */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
{/* Rent */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
|
||||
</Typography>
|
||||
|
||||
{/* Created */}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{formatDate(p.createdAt)}
|
||||
</Typography>
|
||||
|
||||
{/* Status toggle */}
|
||||
<Box>
|
||||
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.status === 'ACTIVE'}
|
||||
onChange={() => handleToggleStatus(p)}
|
||||
disabled={updateProperty.isPending}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Delete */}
|
||||
<Box>
|
||||
<Tooltip title="Inserat löschen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setConfirmDelete(p)}
|
||||
disabled={removeProperty.isPending}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{/* Detail / edit drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
|
||||
>
|
||||
{detailId && (
|
||||
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Suchabo dialog */}
|
||||
<Dialog
|
||||
open={!!suchaboDialog}
|
||||
onClose={() => setSuchaboDialog(null)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{ paper: { sx: { maxHeight: '85vh' } } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem', pb: 1 }}>
|
||||
Suchabos
|
||||
{suchaboDialog && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 400 }}>
|
||||
{suchaboDialog.matches.length} Suchabo{suchaboDialog.matches.length !== 1 ? 's' : ''} matchen dieses Inserat
|
||||
</Typography>
|
||||
)}
|
||||
</DialogTitle>
|
||||
{suchaboDialog && (
|
||||
<SuchaboDialogContent
|
||||
propertyId={suchaboDialog.propertyId}
|
||||
matches={suchaboDialog.matches}
|
||||
onClose={() => setSuchaboDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!confirmDelete} onClose={() => setConfirmDelete(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Inserat löschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -229,6 +474,9 @@ export default function MyListings() {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* OfferWizard — triggered by PublicNeedDetail's "Angebot erstellen" button */}
|
||||
<OfferWizard />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ export default function NewListing() {
|
||||
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
|
||||
parking={form.parking} onParkingChange={form.setParking}
|
||||
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
|
||||
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
|
||||
isFlexible={form.isFlexible} onIsFlexibleChange={form.setIsFlexible}
|
||||
minLettableSqm={form.minLettableSqm} onMinLettableSqmChange={form.setMinLettableSqm}
|
||||
/>
|
||||
|
||||
<ImageUrlSection
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { Box, Button, Chip, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { DecisionContextPanel } from '../../components/ui'
|
||||
import { ViewToggle } from '../../components/shared'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||
@@ -56,6 +56,9 @@ export default function Properties() {
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
const [headerOpen, setHeaderOpen] = useState(() =>
|
||||
localStorage.getItem('props-header-open') !== 'false'
|
||||
)
|
||||
|
||||
const { data: properties = [], isLoading, isError } = useProperties()
|
||||
|
||||
@@ -103,54 +106,92 @@ export default function Properties() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Collapse toggle strip */}
|
||||
<Box
|
||||
onClick={() => {
|
||||
const next = !headerOpen
|
||||
setHeaderOpen(next)
|
||||
localStorage.setItem('props-header-open', String(next))
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 3,
|
||||
py: 0.5,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
'&:hover': { bgcolor: '#f1f5f9' },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: '#94a3b8', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||
Filter & Übersicht
|
||||
</Typography>
|
||||
{headerOpen ? <ChevronUp size={12} color="#94a3b8" /> : <ChevronDown size={12} color="#94a3b8" />}
|
||||
</Box>
|
||||
|
||||
<Collapse in={headerOpen}>
|
||||
{!isLoading && properties.length > 0 && (
|
||||
<DecisionContextPanel
|
||||
decision="Welche Objekte sind matchbereit — und wo blockieren Datenlücken Matches?"
|
||||
context="Objekte mit fehlenden Pflichtfeldern oder Konfidenz < 55% erscheinen im Match-Center nicht oder mit niedrigem Rang."
|
||||
metrics={[
|
||||
{ label: 'matchbereit', value: matchReady.length, severity: matchReady.length > 0 ? 'positive' : 'warning' },
|
||||
...(criticalGaps.length > 0
|
||||
? [{ label: 'kritische Datenlücken', value: criticalGaps.length, severity: 'critical' as const }]
|
||||
: []
|
||||
),
|
||||
...(lowConfidence.length > 0
|
||||
? [{ label: 'Konfidenz < 55%', value: lowConfidence.length, severity: 'warning' as const }]
|
||||
: []
|
||||
),
|
||||
...(staleOrOutdated.length > 0
|
||||
? [{ label: 'veraltete Daten', value: staleOrOutdated.length, severity: 'warning' as const }]
|
||||
: []
|
||||
),
|
||||
]}
|
||||
missing={allMissingFields.length > 0
|
||||
? [`Fehlende Pflichtfelder bei ${criticalGaps.length} Objekten: ${allMissingFields.join(', ')}`]
|
||||
: []
|
||||
}
|
||||
risks={[
|
||||
...(criticalGaps.length > 0
|
||||
? [`${criticalGaps.length} Objekte werden potenziellen Mietern nicht angezeigt`]
|
||||
: []
|
||||
),
|
||||
...(staleOrOutdated.length > 0
|
||||
? [`${staleOrOutdated.length} Objekte mit veralteten Preisen oder Verfügbarkeiten`]
|
||||
: []
|
||||
),
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: 'Datenpflege starten',
|
||||
primary: criticalGaps.length > 0 || staleOrOutdated.length > 0,
|
||||
onClick: () => navigate('/supply/data-quality'),
|
||||
},
|
||||
]}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 0.75,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
borderLeft: '3px solid #1e3a5f',
|
||||
flexShrink: 0,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`${matchReady.length} matchbereit`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600,
|
||||
bgcolor: matchReady.length > 0 ? '#f0fdf4' : '#fef9c3',
|
||||
color: matchReady.length > 0 ? '#1a7a4a' : '#92400e' }}
|
||||
/>
|
||||
{criticalGaps.length > 0 && (
|
||||
<Chip
|
||||
label={`${criticalGaps.length} kritische Datenlücken`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef2f2', color: '#991b1b' }}
|
||||
/>
|
||||
)}
|
||||
{staleOrOutdated.length > 0 && (
|
||||
<Chip
|
||||
label={`${staleOrOutdated.length} veraltete Daten`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef9c3', color: '#92400e' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant={criticalGaps.length > 0 || staleOrOutdated.length > 0 ? 'contained' : 'outlined'}
|
||||
onClick={() => navigate('/supply/data-quality')}
|
||||
sx={criticalGaps.length > 0 || staleOrOutdated.length > 0
|
||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
|
||||
: { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
|
||||
}
|
||||
>
|
||||
Datenpflege
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
</Collapse>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 1.5, p: 1.5 }}>
|
||||
{filtered.map(p => (
|
||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
|
||||
))}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function SupplyDashboard() {
|
||||
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
||||
|
||||
{/* KPI strip */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' }, divideX: true }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' } }}>
|
||||
{/* Starke Matches */}
|
||||
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||
|
||||
@@ -19,11 +19,15 @@ export function buildCreatePropertyInput(fields: {
|
||||
ceilingHeight: string
|
||||
images: string[]
|
||||
floorPlanUrl: string
|
||||
mieterausbaubeitrag?: string
|
||||
isFlexible?: boolean
|
||||
minLettableSqm?: string
|
||||
}): CreatePropertyInput {
|
||||
const {
|
||||
assetType, street, houseNumber, postalCode, city,
|
||||
areaSqm, rentPerSqm, availableFrom, description,
|
||||
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||
mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||
} = fields
|
||||
|
||||
const sf = {
|
||||
@@ -41,6 +45,7 @@ export function buildCreatePropertyInput(fields: {
|
||||
const hf = {
|
||||
floor: floor ? parseInt(floor) : undefined,
|
||||
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
|
||||
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
|
||||
parking: parking ? parseInt(parking) : undefined,
|
||||
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
|
||||
}
|
||||
@@ -76,5 +81,15 @@ export function buildCreatePropertyInput(fields: {
|
||||
warnings: [],
|
||||
},
|
||||
status: 'ACTIVE',
|
||||
units: isFlexible ? [{
|
||||
id: `unit-listing-${Date.now()}`,
|
||||
propertyId: '',
|
||||
floorLevel: hf.floor ?? 0,
|
||||
areaSqm,
|
||||
available: true,
|
||||
rentPricePerSqm: rentPerSqm,
|
||||
isFlexible: true,
|
||||
minLettableSqm: minLettableSqm ? parseInt(minLettableSqm) : undefined,
|
||||
}] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||
|
||||
export interface IMarktHinweisProvider {
|
||||
getAll(): Promise<MarktHinweis[]>
|
||||
getById(id: string): Promise<MarktHinweis | null>
|
||||
create(data: CreateMarktHinweisInput): Promise<MarktHinweis>
|
||||
update(id: string, data: Partial<MarktHinweis>): Promise<MarktHinweis>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IMarktHinweisProvider } from './IMarktHinweisProvider'
|
||||
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||
import { marktHinweise as seed } from '../mock-data/marktHinweise'
|
||||
|
||||
const store: MarktHinweis[] = [...seed]
|
||||
|
||||
export const MockupMarktHinweisProvider: IMarktHinweisProvider = {
|
||||
async getAll() {
|
||||
return [...store]
|
||||
},
|
||||
|
||||
async getById(id: string) {
|
||||
return store.find(h => h.id === id) ?? null
|
||||
},
|
||||
|
||||
async create(data: CreateMarktHinweisInput) {
|
||||
const next: MarktHinweis = {
|
||||
...data,
|
||||
id: `mh-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'Thomas Müller',
|
||||
verwaltungId: 'v-001',
|
||||
verwaltungName: 'Wincasa AG',
|
||||
}
|
||||
store.push(next)
|
||||
return next
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<MarktHinweis>) {
|
||||
const idx = store.findIndex(h => h.id === id)
|
||||
if (idx === -1) throw new Error(`MarktHinweis ${id} not found`)
|
||||
const updated: MarktHinweis = { ...store[idx], ...data }
|
||||
store[idx] = updated
|
||||
return updated
|
||||
},
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe('futureSignalService', () => {
|
||||
const result = await futureSignalService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
})
|
||||
|
||||
it('each signal has required fields', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('matchService', () => {
|
||||
const result = await matchService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
})
|
||||
|
||||
it('each match has required fields', async () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ describe('needService', () => {
|
||||
const result = await needService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta.page).toBe(1)
|
||||
expect(result.meta.hasMore).toBe(false)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
expect(result.meta!.page).toBe(1)
|
||||
expect(result.meta!.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('each need has required fields', async () => {
|
||||
@@ -49,6 +49,9 @@ describe('needService', () => {
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const },
|
||||
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,
|
||||
}
|
||||
const result = await needService.create(input)
|
||||
expect(result.data.companyName).toBe('Test GmbH')
|
||||
@@ -79,6 +82,9 @@ describe('needService', () => {
|
||||
preferredLocations: ['Bern'],
|
||||
budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const },
|
||||
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,
|
||||
})
|
||||
const id = created.data.id
|
||||
await needService.remove(id)
|
||||
|
||||
@@ -7,8 +7,8 @@ describe('propertyService', () => {
|
||||
const result = await propertyService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta.hasMore).toBe(false)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
expect(result.meta!.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('each property has required fields', async () => {
|
||||
@@ -62,9 +62,14 @@ describe('propertyService', () => {
|
||||
assetType: 'LOGISTICS' as const,
|
||||
resultType: 'VERIFIED_PORTFOLIO' as const,
|
||||
location: { city: 'Basel', country: 'CH' as const },
|
||||
address: { street: 'Teststr.', houseNumber: '1', postalCode: '4000', city: 'Basel', country: 'CH' },
|
||||
areaSqm: 500,
|
||||
rentPricePerSqm: 120,
|
||||
availabilityDate: '2025-01-01',
|
||||
availabilityStatus: 'AVAILABLE_NOW' as const,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.8,
|
||||
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH' as const, warnings: [] },
|
||||
organizationId: 'org-test',
|
||||
}
|
||||
const result = await propertyService.create(input)
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
|
||||
export interface AIProvenance {
|
||||
/** Which AI provider produced this response */
|
||||
provider: 'openrouter' | 'mock'
|
||||
provider: 'openrouter' | 'mock' | 'backend'
|
||||
/** Exact model ID (e.g. 'anthropic/claude-3-5-haiku') or 'mock' */
|
||||
model: string
|
||||
/** ISO-8601 timestamp of generation */
|
||||
@@ -157,6 +157,25 @@ export interface DataQualityInput {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
// ── Fit-out advice ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FitOutAdviceInput {
|
||||
fitOut: string
|
||||
areaSqm: number
|
||||
mabPerSqm: number
|
||||
requiredFitOut?: string
|
||||
tenantBudgetPerSqm?: number
|
||||
monthlyRentPerSqm: number
|
||||
}
|
||||
|
||||
export interface FitOutAdvice {
|
||||
recommendation: 'MIETERAUSBAU' | 'BKZ' | 'MAB_AMORTISATION'
|
||||
headline: string
|
||||
explanation: string
|
||||
negotiationTip: string
|
||||
estimatedNetInvestment: string
|
||||
}
|
||||
|
||||
// ── Legacy types (kept for backward compatibility) ────────────────────────────
|
||||
|
||||
export interface CriteriaExtractionResult {
|
||||
@@ -198,6 +217,9 @@ export interface IAIService {
|
||||
// Offer email (supply side)
|
||||
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
|
||||
|
||||
// Fit-out investment advice (demand side)
|
||||
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
|
||||
|
||||
// Legacy methods
|
||||
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
|
||||
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user