Compare commits
28 Commits
11de05025c
..
Update
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a2507979 | |||
| 87ea4c4dfc | |||
| c6d3071010 | |||
| 5a1f412ce3 | |||
| b2530f9e20 | |||
| a825672197 | |||
| 36570c5bdc | |||
| 98d2770054 | |||
| 6fb2fb027c | |||
| 7f6b7766e6 | |||
| 63909c8caf | |||
| d8ff380d6e | |||
| 81d66d24a9 | |||
| e597e81ff5 | |||
| d2af8664f4 | |||
| cd220ed282 | |||
| a9ee53f185 | |||
| fe8444c1d3 | |||
| 453f2b1982 | |||
| d804d1c923 | |||
| a9e038a64f | |||
| 61a26d00a7 | |||
| f2edc3b2ef | |||
| e95490eb72 | |||
| 3ab6eeccf2 | |||
| 128d28af8d | |||
| 9df5d285f5 | |||
| 609a3634bd |
@@ -11,7 +11,9 @@
|
|||||||
"Bash(start http://localhost:5173)",
|
"Bash(start http://localhost:5173)",
|
||||||
"Bash(npm install *)",
|
"Bash(npm install *)",
|
||||||
"Bash(git pull *)",
|
"Bash(git pull *)",
|
||||||
"Bash(node scripts/check-tokens.js)"
|
"Bash(node scripts/check-tokens.js)",
|
||||||
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"Bash(echo \"EXIT_CODE:$?\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -m ' *)",
|
||||||
"Bash(git commit *)"
|
"Bash(git commit *)",
|
||||||
|
"Bash(node -e ' *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
> Why do I have a folder named ".vercel" in my project?
|
||||||
|
The ".vercel" folder is created when you link a directory to a Vercel project.
|
||||||
|
|
||||||
|
> What does the "project.json" file contain?
|
||||||
|
The "project.json" file contains:
|
||||||
|
- The ID of the Vercel project that you linked ("projectId")
|
||||||
|
- The ID of the user or team your Vercel project is owned by ("orgId")
|
||||||
|
|
||||||
|
> Should I commit the ".vercel" folder?
|
||||||
|
No, you should not share the ".vercel" folder with anyone.
|
||||||
|
Upon creation, it will be automatically added to your ".gitignore" file.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
|
||||||
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 FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||||
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
||||||
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
|
||||||
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||||
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||||
|
|
||||||
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
|
|||||||
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
||||||
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||||
|
|
||||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -62,7 +61,6 @@ function App() {
|
|||||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
|
||||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export function InquiryReplyComposer({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
bgcolor: 'white',
|
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
|
|||||||
@@ -1,18 +1,86 @@
|
|||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Divider, Typography } from '@mui/material'
|
||||||
import { FileText } from 'lucide-react'
|
import { Building2, FileText, MapPin } from 'lucide-react'
|
||||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
import { mockProperties } from '../../mock-data/properties'
|
import { mockProperties } from '../../mock-data/properties'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
interface MockPdfPreviewProps {
|
interface MockPdfPreviewProps {
|
||||||
fields: Record<string, string>
|
fields: Record<string, string>
|
||||||
fallbackFields: Record<string, string>
|
fallbackFields: Record<string, string>
|
||||||
|
fieldSelections: ReportObjectFieldSelection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
|
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
|
||||||
|
areaSqm: 'Fläche',
|
||||||
|
rentPricePerSqm: 'Mietpreis',
|
||||||
|
availabilityDate: 'Verfügbar ab',
|
||||||
|
leaseTerm: 'Mietlaufzeit',
|
||||||
|
breakoutOption: 'Breakout-Option',
|
||||||
|
currentTenant: 'Aktueller Mieter',
|
||||||
|
propertyNumber: 'Objektnummer',
|
||||||
|
assetType: 'Asset Type',
|
||||||
|
floor: 'Stockwerk',
|
||||||
|
parking: 'Parkplätze',
|
||||||
|
fitOut: 'Ausbaugrad',
|
||||||
|
isBarrierFree: 'Barrierefrei',
|
||||||
|
ceilingHeightM: 'Raumhöhe',
|
||||||
|
floorLoad: 'Bodenlast',
|
||||||
|
loadingDocksCount: 'Anlieferung',
|
||||||
|
goodsLift: 'Warenaufzug',
|
||||||
|
passengerLift: 'Personenaufzug',
|
||||||
|
powerSupplyKva: 'Stromanschluss',
|
||||||
|
hasServerRoom: 'Serverraum',
|
||||||
|
internet: 'Internet',
|
||||||
|
deliveryAccess: 'Zufahrt',
|
||||||
|
publicTransportScore: 'ÖV-Anbindung',
|
||||||
|
prestigeScore: 'Prestige',
|
||||||
|
visibilityScore: 'Sichtbarkeit',
|
||||||
|
footfallScore: 'Passantenfrequenz',
|
||||||
|
commuterAccessScore: 'Pendlererreichbarkeit',
|
||||||
|
talentAccessScore: 'Talent Access',
|
||||||
|
esgScore: 'ESG',
|
||||||
|
flexibilityScore: 'Flexibilität',
|
||||||
|
expansionPotentialScore: 'Expansionspotenzial',
|
||||||
|
taxEnvironmentScore: 'Steuerumfeld',
|
||||||
|
microLocation: 'Mikrostandort',
|
||||||
|
competitionEnvironment: 'Konkurrenzumfeld',
|
||||||
|
infrastructure: 'Infrastruktur',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
|
||||||
|
switch (key) {
|
||||||
|
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : null
|
||||||
|
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
|
||||||
|
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
|
||||||
|
case 'leaseTerm': return property.leaseTerm ?? null
|
||||||
|
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
|
||||||
|
case 'currentTenant': return property.currentTenant ?? null
|
||||||
|
case 'propertyNumber': return property.propertyNumber ?? null
|
||||||
|
case 'assetType': return property.assetType ?? null
|
||||||
|
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
|
||||||
|
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
|
||||||
|
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
|
||||||
|
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
|
||||||
|
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
|
||||||
|
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
|
||||||
|
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
|
||||||
|
case 'fitOut': return property.hardFacts?.fitOut ?? null
|
||||||
|
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
|
||||||
|
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
|
||||||
|
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
|
||||||
|
case 'description': return property.description ?? null
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
|
||||||
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
|
||||||
|
|
||||||
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
||||||
|
|
||||||
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* ── Page 1 — Offer letter ── */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
||||||
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
|||||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||||
{v('closing')}
|
{v('closing')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* ── Page 2 — Property details side by side ── */}
|
||||||
|
{hasPropertyPage && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||||
|
Seite 2 — Objektdetails
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{properties.map(p => {
|
||||||
|
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
|
||||||
|
const optionalFields = selection?.selectedOptionalFields ?? []
|
||||||
|
const image = p.images?.[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Photo */}
|
||||||
|
{image ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={image}
|
||||||
|
alt={p.title}
|
||||||
|
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Building2 size={22} color="#94a3b8" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<Box sx={{ p: 1.25, flex: 1 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
|
||||||
|
<MapPin size={10} color="#94a3b8" />
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
|
||||||
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Selected fields */}
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
|
{optionalFields.map(key => {
|
||||||
|
const val = getFieldValue(p, key)
|
||||||
|
if (!val) return null
|
||||||
|
return (
|
||||||
|
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
|
||||||
|
{FIELD_LABELS[key] ?? key}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
|
||||||
|
{val}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
||||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
@@ -187,7 +187,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
size="small"
|
size="small"
|
||||||
value={a.date}
|
value={a.date}
|
||||||
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||||
InputLabelProps={{ shrink: true }}
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
sx={{ width: 160 }}
|
sx={{ width: 160 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
ready={ready}
|
ready={ready}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
property={property}
|
property={property ?? undefined}
|
||||||
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||||
onAttach={() => {
|
onAttach={() => {
|
||||||
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Box, Button, Stack, Typography } from '@mui/material'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||||
|
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
|
|
||||||
|
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
|
||||||
|
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
||||||
|
|
||||||
|
export function OfferFieldSelectionStep() {
|
||||||
|
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
|
||||||
|
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
|
||||||
|
const setStep = useOfferWizardStore(s => s.setStep)
|
||||||
|
|
||||||
|
const { data: allProperties = [] } = useProperties()
|
||||||
|
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
|
||||||
|
|
||||||
|
// Initialise selections when properties are loaded or change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProperties.length === 0) return
|
||||||
|
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
|
||||||
|
const missing = selectedProperties.filter(p => !existing.has(p.id))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setFieldSelections([
|
||||||
|
...fieldSelections,
|
||||||
|
...missing.map(p => ({
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
// run only when property list or selections change length
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedProperties.length, fieldSelections.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
|
||||||
|
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
|
||||||
|
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{selectedProperties.map(p => {
|
||||||
|
const selection: ReportObjectFieldSelection =
|
||||||
|
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
|
||||||
|
propertyId: p.id,
|
||||||
|
mandatoryFields: MANDATORY_FIELDS,
|
||||||
|
selectedOptionalFields: DEFAULT_FIELDS,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ReportObjectFieldSelector
|
||||||
|
key={p.id}
|
||||||
|
propertyTitle={p.title}
|
||||||
|
value={selection}
|
||||||
|
onChange={v => updateFieldSelection(p.id, v)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => setStep('select_properties')}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => setStep('pdf_review')}
|
||||||
|
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||||
|
|
||||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||||
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||||
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||||
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
|
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOfferDraftId(res.data.id)
|
setOfferDraftId(res.data.id)
|
||||||
setStep('pdf_review')
|
setStep('select_fields')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!need) {
|
if (!need) {
|
||||||
|
|||||||
@@ -12,22 +12,25 @@ import {
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
||||||
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||||
|
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
|
||||||
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||||
import { OfferCheckedAction } from './OfferCheckedAction'
|
import { OfferCheckedAction } from './OfferCheckedAction'
|
||||||
import { OfferChatComposer } from './OfferChatComposer'
|
import { OfferChatComposer } from './OfferChatComposer'
|
||||||
|
|
||||||
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
|
// 'checked' is internal — merged into pdf_review step; 4 visible steps
|
||||||
const STEPS: { key: OfferStep; label: string }[] = [
|
const STEPS: { key: OfferStep; label: string }[] = [
|
||||||
{ key: 'select_properties', label: 'Objekte wählen' },
|
{ key: 'select_properties', label: 'Objekte wählen' },
|
||||||
|
{ key: 'select_fields', label: 'Felder wählen' },
|
||||||
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
||||||
{ key: 'send', label: 'Senden' },
|
{ key: 'send', label: 'Senden' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
||||||
select_properties: 0,
|
select_properties: 0,
|
||||||
pdf_review: 1,
|
select_fields: 1,
|
||||||
checked: 1, // same visual step as pdf_review
|
pdf_review: 2,
|
||||||
send: 2,
|
checked: 2, // same visual step as pdf_review
|
||||||
|
send: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OfferWizard() {
|
export function OfferWizard() {
|
||||||
@@ -48,13 +51,15 @@ export function OfferWizard() {
|
|||||||
fullScreen={fullScreen}
|
fullScreen={fullScreen}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
slotProps={{
|
||||||
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
height: fullScreen ? '100vh' : '85vh',
|
height: fullScreen ? '100vh' : '85vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -112,6 +117,7 @@ export function OfferWizard() {
|
|||||||
{/* Step content */}
|
{/* Step content */}
|
||||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
||||||
|
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
|
||||||
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
||||||
{currentStep === 'checked' && <OfferCheckedAction />}
|
{currentStep === 'checked' && <OfferCheckedAction />}
|
||||||
{currentStep === 'send' && <OfferChatComposer />}
|
{currentStep === 'send' && <OfferChatComposer />}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Download, Send, X } from 'lucide-react'
|
import { Download, Send, X } from 'lucide-react'
|
||||||
import type { Inquiry } from '../../domain/inquiry'
|
import type { Inquiry } from '../../domain/inquiry'
|
||||||
import type { Property } from '../../domain/property'
|
|
||||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||||
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
@@ -40,10 +39,6 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
|
|
||||||
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
||||||
|
|
||||||
const selectedProperties = draft?.selectedPropertyIds
|
|
||||||
.map(id => allProperties.find(p => p.id === id))
|
|
||||||
.filter((p): p is Property => !!p) ?? []
|
|
||||||
|
|
||||||
const toggleProperty = (id: string) => {
|
const toggleProperty = (id: string) => {
|
||||||
setSelectedIds(prev =>
|
setSelectedIds(prev =>
|
||||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||||
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
|
||||||
|
|
||||||
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
||||||
const isSelected = (key: ReportObjectFieldKey) =>
|
const isSelected = (key: ReportObjectFieldKey) =>
|
||||||
value.selectedOptionalFields.includes(key)
|
value.selectedOptionalFields.includes(key)
|
||||||
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
|
|||||||
|
|
||||||
{FIELD_GROUPS.map(group => {
|
{FIELD_GROUPS.map(group => {
|
||||||
const groupKeys = group.fields.map(f => f.key)
|
const groupKeys = group.fields.map(f => f.key)
|
||||||
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
|
|
||||||
return (
|
return (
|
||||||
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
||||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Chip } from '@mui/material'
|
import { Chip } from '@mui/material'
|
||||||
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
|
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
|
||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import { DS_COLORS } from '../../lib/ds'
|
import { DS_COLORS } from '../../lib/ds'
|
||||||
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
||||||
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
|
|||||||
|
|
||||||
const ICONS: Record<ResultType, React.ElementType> = {
|
const ICONS: Record<ResultType, React.ElementType> = {
|
||||||
VERIFIED_PORTFOLIO: ShieldCheck,
|
VERIFIED_PORTFOLIO: ShieldCheck,
|
||||||
EXTERNAL_MARKET: Globe,
|
|
||||||
MAISON_WORK: Building2,
|
MAISON_WORK: Building2,
|
||||||
FUTURE_AVAILABILITY: Sparkles,
|
FUTURE_AVAILABILITY: Sparkles,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
|
|||||||
|
|
||||||
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
||||||
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
||||||
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
|
|
||||||
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
|
||||||
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { TableCell, TableRow } from '@mui/material'
|
||||||
|
import { LABEL_SX, DATA_SX } from './compareUtils'
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CompareMetricRowProps {
|
||||||
|
label: string
|
||||||
|
cells: ReactNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CompareMetricRow = memo(function CompareMetricRow({ label, cells }: CompareMetricRowProps) {
|
||||||
|
return (
|
||||||
|
<TableRow hover>
|
||||||
|
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||||
|
{cells.map((cell, i) => (
|
||||||
|
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
import { CompareCell, MissingDataCell } from './index'
|
import { CompareCell, MissingDataCell } from './index'
|
||||||
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
|
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
|
||||||
import { matchScoreHex } from '../../lib/utils'
|
import { matchScoreHex } from '../../lib/utils'
|
||||||
|
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||||
|
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
// ── Local helper ──────────────────────────────────────────────────────────────
|
// ── Local helper ──────────────────────────────────────────────────────────────
|
||||||
@@ -148,18 +150,70 @@ export function CompareTableBody({
|
|||||||
: <MissingDataCell />
|
: <MissingDataCell />
|
||||||
}))}
|
}))}
|
||||||
|
|
||||||
{/* 9. Rent / Budget Fit */}
|
{/* 9. Rent / Budget Fit — full cost breakdown incl. amortised fit-out */}
|
||||||
{row('9. Miete / Budget', compareItems.map(item => {
|
{row('9. Kosten / Budget', compareItems.map(item => {
|
||||||
const prop = getProp(item)
|
const prop = getProp(item)
|
||||||
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
|
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
|
||||||
|
|
||||||
|
const FIT_OUT_LABELS: Record<string, string> = {
|
||||||
|
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
|
||||||
|
}
|
||||||
|
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
|
||||||
|
|
||||||
|
const monthlyRent = prop.totalRentMonthly
|
||||||
|
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
|
||||||
|
// ancillaryCosts stored as CHF/m²/Monat
|
||||||
|
const monthlyNebenkosten = prop.ancillaryCosts != null
|
||||||
|
? Math.round(prop.ancillaryCosts * prop.areaSqm)
|
||||||
|
: null
|
||||||
|
const fitOut = prop.hardFacts?.fitOut
|
||||||
|
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
|
||||||
|
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
|
||||||
|
|
||||||
|
// Amortised fit-out monthly cost (mid-range estimate)
|
||||||
|
let fitOutMonthly = 0
|
||||||
|
let fitOutMonthlyLabel: string | null = null
|
||||||
|
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
|
||||||
|
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
|
||||||
|
if (inv && !inv.isFullyCovered) {
|
||||||
|
const months = FITOUT_AMORTIZATION_YEARS * 12
|
||||||
|
const midMin = Math.round(inv.netTotal.min / months)
|
||||||
|
const midMax = Math.round(inv.netTotal.max / months)
|
||||||
|
fitOutMonthly = Math.round((midMin + midMax) / 2)
|
||||||
|
fitOutMonthlyLabel = midMin === midMax
|
||||||
|
? `${midMin.toLocaleString('de-CH')}`
|
||||||
|
: `${midMin.toLocaleString('de-CH')}–${midMax.toLocaleString('de-CH')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMonthly = monthlyRent
|
||||||
|
+ (monthlyNebenkosten ?? 0)
|
||||||
|
+ fitOutMonthly
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
CHF {prop.rentPricePerSqm}/m²/Jahr
|
CHF {prop.rentPricePerSqm}/m²/Jahr
|
||||||
</Typography>
|
</Typography>
|
||||||
{prop.totalRentMonthly && (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
|
{monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete)
|
||||||
|
</Typography>
|
||||||
|
{monthlyNebenkosten != null && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
+ {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{fitOutMonthlyLabel && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
|
||||||
|
= {totalMonthly.toLocaleString('de-CH')} CHF/Monat
|
||||||
|
</Typography>
|
||||||
|
{fitOutLabel && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
|
||||||
|
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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 { Save } from 'lucide-react'
|
||||||
import { NeedCardPreview } from './NeedCardPreview'
|
import { NeedCardPreview } from './NeedCardPreview'
|
||||||
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
|
||||||
@@ -10,7 +10,9 @@ interface Props {
|
|||||||
needTitle: string
|
needTitle: string
|
||||||
overallConfidence: number
|
overallConfidence: number
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
|
isAnonymous: boolean
|
||||||
onNeedTitleChange: (t: string) => void
|
onNeedTitleChange: (t: string) => void
|
||||||
|
onAnonymousChange: (v: boolean) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
}
|
}
|
||||||
@@ -22,7 +24,9 @@ export function AISearchSavePreview({
|
|||||||
needTitle,
|
needTitle,
|
||||||
overallConfidence,
|
overallConfidence,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
isAnonymous,
|
||||||
onNeedTitleChange,
|
onNeedTitleChange,
|
||||||
|
onAnonymousChange,
|
||||||
onBack,
|
onBack,
|
||||||
onSave,
|
onSave,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -39,7 +43,41 @@ export function AISearchSavePreview({
|
|||||||
needTitle={needTitle}
|
needTitle={needTitle}
|
||||||
onNeedTitleChange={onNeedTitleChange}
|
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}>
|
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
|
||||||
← Zurück
|
← Zurück
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
|
|||||||
inq: Inquiry
|
inq: Inquiry
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
hasPipeline: boolean
|
hasPipeline: boolean
|
||||||
|
perspective?: 'demand' | 'supply'
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
|
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
|
||||||
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
|
||||||
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
|
||||||
const lastMsg = inq.thread[inq.thread.length - 1]
|
const lastMsg = inq.thread[inq.thread.length - 1]
|
||||||
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
const unreadColor = cfg?.fg ?? DS_TEXT.danger
|
||||||
|
|
||||||
|
const primaryTitle = perspective === 'demand'
|
||||||
|
? (inq.propertyAddress ?? inq.subject)
|
||||||
|
: inq.tenantName
|
||||||
|
|
||||||
|
const secondaryLine = perspective === 'demand'
|
||||||
|
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
|
||||||
|
: (inq.tenantCompany ?? '')
|
||||||
|
|
||||||
|
const lastMsgPrefix = (() => {
|
||||||
|
if (!lastMsg) return ''
|
||||||
|
if (perspective === 'demand') {
|
||||||
|
return lastMsg.senderType === 'tenant'
|
||||||
|
? 'Sie: '
|
||||||
|
: `${inq.propertyManagerName ?? 'Verwalter'}: `
|
||||||
|
}
|
||||||
|
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box onClick={() => onSelect(inq.id)} sx={{
|
<Box onClick={() => onSelect(inq.id)} sx={{
|
||||||
px: 2, py: 1.5,
|
px: 2, py: 1.5,
|
||||||
@@ -31,7 +50,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
|
||||||
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
|
||||||
{inq.tenantName}
|
{primaryTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||||
@@ -43,9 +62,9 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{inq.tenantCompany && (
|
{secondaryLine && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
|
||||||
{inq.tenantCompany}
|
{secondaryLine}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
|
||||||
@@ -53,7 +72,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
|
|||||||
</Typography>
|
</Typography>
|
||||||
{lastMsg && (
|
{lastMsg && (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
|
||||||
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
|
{lastMsgPrefix}
|
||||||
{lastMsg.body.split('\n')[0]}
|
{lastMsg.body.split('\n')[0]}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
|
|||||||
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||||
import type { InquiryMessage } from '../../domain/inquiry'
|
import type { InquiryMessage } from '../../domain/inquiry'
|
||||||
|
|
||||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
|
||||||
const isOwnMessage = msg.senderType === 'tenant'
|
const isOwnMessage = msg.senderType === 'tenant'
|
||||||
const isAI = msg.senderType === 'ai'
|
const isAI = msg.senderType === 'ai'
|
||||||
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
|||||||
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
{msg.senderName} · {date} {time}
|
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
const ASSET_LABELS: Record<string, string> = {
|
const ASSET_LABELS: Record<string, string> = {
|
||||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||||
|
|||||||
@@ -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 { ChevronDown } from 'lucide-react'
|
||||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
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 } }}
|
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
||||||
|
</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>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||||
import { AssetType } from '../../domain/enums'
|
import { AssetType } from '../../domain/enums'
|
||||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
|||||||
{ label: 'Retail', value: AssetType.RETAIL },
|
{ label: 'Retail', value: AssetType.RETAIL },
|
||||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
|
|||||||
|
|
||||||
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
|
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 }) {
|
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
<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>
|
</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 */}
|
{/* Budget */}
|
||||||
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
|
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
|
||||||
@@ -220,6 +242,30 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
</Stack>
|
</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} />
|
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useCompareStore } from '../../stores/compareStore'
|
|||||||
|
|
||||||
const TYPE_DOT: Record<string, string> = {
|
const TYPE_DOT: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||||
EXTERNAL_MARKET: '#d97706',
|
|
||||||
MAISON_WORK: '#0369a1',
|
MAISON_WORK: '#0369a1',
|
||||||
FUTURE_AVAILABILITY: '#7c3aed',
|
FUTURE_AVAILABILITY: '#7c3aed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
Target,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Search,
|
Search,
|
||||||
List,
|
List,
|
||||||
@@ -13,7 +12,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Kanban,
|
Kanban,
|
||||||
BellRing,
|
BellRing,
|
||||||
Plus,
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -50,12 +49,10 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||||
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
||||||
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
|
|
||||||
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
||||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
|
||||||
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
|
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
||||||
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
[WorkspaceType.DEMAND]: {
|
[WorkspaceType.DEMAND]: {
|
||||||
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
[WorkspaceType.OPERATIONS]: {
|
||||||
|
label: 'Operations',
|
||||||
|
abbreviation: 'OP',
|
||||||
|
icon: Settings,
|
||||||
|
firstPath: '/ops/review-queue',
|
||||||
|
chipColor: '#6b21a8',
|
||||||
|
navItems: [
|
||||||
|
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
|
||||||
|
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordered list for rendering workspace tabs
|
// Ordered list for rendering workspace tabs
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import { Box, Button, Divider, Typography } from '@mui/material'
|
import { Box, Button, Divider, Typography } from '@mui/material'
|
||||||
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
|||||||
|
|
||||||
// ── Future Availability Card ──────────────────────────────────────────────────
|
// ── Future Availability Card ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||||
const isControlled = vm.signalIsControlled ?? false
|
const isControlled = vm.signalIsControlled ?? false
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -243,4 +244,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { LocationPreview } from '../shared/LocationPreview'
|
||||||
import { HeatBadge } from '../shared/HeatBadge'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
@@ -19,12 +20,15 @@ interface Props {
|
|||||||
lat?: number
|
lat?: number
|
||||||
lng?: number
|
lng?: number
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||||
@@ -51,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
|
|
||||||
{/* ── Hero zone ── */}
|
{/* ── Hero zone ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
position: 'absolute', top: 10, left: 10,
|
position: 'absolute', top: 10, left: 10,
|
||||||
background: theme.gradient, borderRadius: '10px',
|
background: theme.gradient, borderRadius: '10px',
|
||||||
@@ -140,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
|
matchScore: vm.matchScore,
|
||||||
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
{vm.actions.map(a => (
|
{vm.actions.map(a => (
|
||||||
<Button
|
<Button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
|
|||||||
@@ -1,119 +1,245 @@
|
|||||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
import { memo } from 'react'
|
||||||
import { MapPin } from 'lucide-react'
|
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||||
import { MatchCardHeader } from './MatchCardHeader'
|
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||||
import { MatchReasonList } from './MatchReasonList'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { TradeoffList } from './TradeoffList'
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
import { HeatBadge } from '../shared/HeatBadge'
|
||||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||||
|
import { confidenceHex } from '../../lib/utils'
|
||||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||||
|
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vm: MatchCardViewModel
|
vm: MatchCardViewModel
|
||||||
|
imageUrl?: string
|
||||||
|
overlayTypeLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchCardCompact({ vm }: Props) {
|
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||||
|
|
||||||
const borderColor = vm.isCompareSelected
|
const { currentUser } = useSessionStore()
|
||||||
? '#7c3aed'
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
: vm.isSelected
|
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
? '#1e3a5f'
|
|
||||||
: 'transparent'
|
const tier = getScoreTier(vm.matchScore)
|
||||||
|
const theme = SCORE_THEME[tier]
|
||||||
|
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||||
|
const confPct = Math.round(vm.confidenceScore * 100)
|
||||||
|
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
|
||||||
|
|
||||||
|
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Box sx={{
|
||||||
sx={{
|
display: 'flex',
|
||||||
p: 2.5,
|
|
||||||
mb: 1.5,
|
mb: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
border: `2px solid ${borderColor}`,
|
border: `2px solid ${borderColor}`,
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
||||||
|
background: theme.cardBg,
|
||||||
opacity: vm.isStaleData ? 0.75 : 1,
|
opacity: vm.isStaleData ? 0.75 : 1,
|
||||||
transition: 'border-color 0.15s',
|
transition: 'box-shadow 0.15s',
|
||||||
}}
|
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
|
||||||
>
|
}}>
|
||||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
|
||||||
|
{/* ── Image strip ── */}
|
||||||
|
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{
|
||||||
|
width: '100%', height: '100%', minHeight: 120,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
|
||||||
|
}}>
|
||||||
|
<MapPin size={24} color="#7ba3bf" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Score badge */}
|
||||||
|
<Box sx={{
|
||||||
|
position: 'absolute', top: 8, left: 8,
|
||||||
|
background: theme.gradient, borderRadius: '8px',
|
||||||
|
px: 1, py: 0.25,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: `0 2px 8px ${theme.glow}`,
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
|
||||||
|
{vm.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Asset type label */}
|
||||||
|
{overlayTypeLabel && (
|
||||||
|
<Box sx={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||||
|
px: 0.75, py: 0.5,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
|
||||||
|
}}>
|
||||||
|
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
{vm.disclaimer && (
|
{vm.disclaimer && (
|
||||||
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||||
{vm.disclaimer}
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{vm.isReviewRequired && (
|
|
||||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
{/* Badges row */}
|
||||||
Manuelle Überprüfung erforderlich
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
</Alert>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||||
|
<Chip label={rt.label} size="small"
|
||||||
|
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
|
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||||
|
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
|
||||||
|
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||||
)}
|
)}
|
||||||
|
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||||
|
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||||
|
{vm.availabilityLabel && (
|
||||||
|
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||||
|
)}
|
||||||
|
{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'}
|
||||||
|
size="small" color="error" variant="outlined"
|
||||||
|
sx={{ fontSize: 10, height: 20 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Header: score → type → confidence → availability → risk */}
|
{/* Title + location */}
|
||||||
<MatchCardHeader vm={vm} compact />
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||||
|
|
||||||
{/* Title + location (max 2 lines) */}
|
|
||||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
|
||||||
{vm.title}
|
{vm.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
{vm.propertySubtitle && (
|
{vm.propertySubtitle && (
|
||||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
|
||||||
{vm.propertySubtitle}
|
{vm.propertySubtitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||||
<MapPin size={13} color="#64748b" />
|
<MapPin size={11} color="#64748b" />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{vm.locationLabel}
|
{vm.locationLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Explainability summary */}
|
||||||
{vm.explainabilitySummary && (
|
{vm.explainabilitySummary && (
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
|
||||||
variant="body2"
|
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
mt: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vm.explainabilitySummary}
|
{vm.explainabilitySummary}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 1.25 }} />
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
{/* Score formula — always visible */}
|
{/* Score breakdown */}
|
||||||
{vm.scoreBreakdown && (
|
{vm.scoreBreakdown && (
|
||||||
<Box sx={{ mb: 1.25 }}>
|
<Box sx={{ mb: 1 }}>
|
||||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top reason only */}
|
{/* Reasons — all 3, same style as grid card */}
|
||||||
{vm.reasons.length > 0 && (
|
{vm.reasons.length > 0 && (
|
||||||
<Box sx={{ mb: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||||
|
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
||||||
|
{r.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
|
||||||
|
{r.explanation}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top tradeoff only (compact) */}
|
{/* Top tradeoff */}
|
||||||
{vm.tradeoffs.length > 0 && (
|
{vm.tradeoffs.length > 0 && (
|
||||||
<Box sx={{ mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||||
|
{vm.tradeoffs[0].concern}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Data quality — only critical warning in compact mode */}
|
{/* Actions */}
|
||||||
<MatchDataQualitySummary
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||||
dataQualityScore={vm.dataQualityScore}
|
<Button size="small" variant="contained" onClick={e => {
|
||||||
missingData={vm.missingData}
|
e.stopPropagation()
|
||||||
compact
|
openInquiryDialog({
|
||||||
/>
|
propertyTitle: vm.title,
|
||||||
|
location: vm.locationLabel ?? '',
|
||||||
<Box sx={{ mt: 1.25 }}>
|
matchScore: vm.matchScore,
|
||||||
<MatchActionToolbar actions={vm.actions} compact />
|
matchId: vm.id,
|
||||||
|
propertyId: vm.propertyId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
||||||
|
Anfrage
|
||||||
|
</Button>
|
||||||
|
{vm.actions.map(a => (
|
||||||
|
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||||
|
onClick={a.onClick} disabled={a.disabled}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
|
||||||
|
{a.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ResultType } from '../../domain/enums'
|
import type { ResultType } from '../../domain/enums'
|
||||||
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
|
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
|
||||||
import type { PropertyUnit } from '../../domain/property'
|
import type { PropertyUnit } from '../../domain/property'
|
||||||
|
import type { FitOutInvestment } from '../../lib/fitOutUtils'
|
||||||
|
|
||||||
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
|
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
|
||||||
|
|
||||||
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
|
|||||||
signalIsControlled?: boolean
|
signalIsControlled?: boolean
|
||||||
signalAreaSqmEstimate?: number
|
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)
|
// Unit-first display (primary)
|
||||||
propertySubtitle?: string // building name shown below the unit title
|
propertySubtitle?: string // building name shown below the unit title
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
|||||||
export { MatchActionToolbar } from './MatchActionToolbar'
|
export { MatchActionToolbar } from './MatchActionToolbar'
|
||||||
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
||||||
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||||
|
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||||
export type {
|
export type {
|
||||||
MatchCardViewModel,
|
MatchCardViewModel,
|
||||||
MatchCardVariant,
|
MatchCardVariant,
|
||||||
|
|||||||
@@ -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' }}>
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
{need && (
|
{need && (
|
||||||
<Chip
|
<Chip
|
||||||
label={need.companyName}
|
label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
|
||||||
size="small"
|
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
|
<Chip
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ export function NeedSelectionPanel({ matches }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, mr: 0.5, minWidth: 0 }}>
|
||||||
{need.companyName}
|
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }} noWrap>
|
||||||
|
{need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
|
||||||
</Typography>
|
</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 && (
|
{pendingCount > 0 && (
|
||||||
<Chip label={pendingCount} size="small"
|
<Chip label={pendingCount} size="small"
|
||||||
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
|
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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'
|
||||||
|
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
|
||||||
|
|
||||||
|
const FIT_OUT_LABELS: Record<string, string> = {
|
||||||
|
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
|
||||||
|
}
|
||||||
|
|
||||||
|
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
|
||||||
|
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 ${AMORTIZATION_YEARS} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Paper, Typography } from '@mui/material'
|
||||||
|
import { FileImage } from 'lucide-react'
|
||||||
|
import { DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface FloorPlan {
|
||||||
|
url: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plans: FloorPlan[]
|
||||||
|
mb?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanSection({ plans, mb }: Props) {
|
||||||
|
if (plans.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<FileImage size={15} color={DS_TEXT.secondary} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{plans.map((p, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
{p.label && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
|
||||||
|
{p.label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={p.url}
|
||||||
|
alt={p.label ?? 'Grundriss'}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 1,
|
||||||
|
border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
display: 'block',
|
||||||
|
objectFit: 'contain',
|
||||||
|
maxHeight: 480,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||||
|
TextField, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Send } from 'lucide-react'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
import { useMoveStage } from '../../hooks/usePipeline'
|
||||||
|
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||||
|
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
|
||||||
|
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryQuickDialog() {
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
const dialogOpen = useInquiryStore(s => s.dialogOpen)
|
||||||
|
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||||
|
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||||
|
const addInquiry = useInquiryStore(s => s.addInquiry)
|
||||||
|
const { mutate: moveStage } = useMoveStage()
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogOpen && pendingInquiry) {
|
||||||
|
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
|
||||||
|
}
|
||||||
|
}, [dialogOpen, pendingInquiry])
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!pendingInquiry) return
|
||||||
|
addInquiry(pendingInquiry, message)
|
||||||
|
if (pendingInquiry.pipelineItemId) {
|
||||||
|
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||||
|
}
|
||||||
|
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
|
||||||
|
closeInquiryDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingInquiry) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Send size={16} />
|
||||||
|
Anfrage senden
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ pt: 0 }}>
|
||||||
|
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{pendingInquiry.location}
|
||||||
|
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
|
||||||
|
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
|
||||||
|
{' · '}Match {pendingInquiry.matchScore}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
|
||||||
|
Der Text kann vor dem Absenden angepasst werden.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||||
|
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!message.trim()}
|
||||||
|
startIcon={<Send size={14} />}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import type { FutureSignal } from '../../domain/futureSignal'
|
|||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ interface MatchDetailHeroProps {
|
|||||||
keyFacts: Array<{ label: string; value: string }>
|
keyFacts: Array<{ label: string; value: string }>
|
||||||
onCompare: () => void
|
onCompare: () => void
|
||||||
onShortlist: () => void
|
onShortlist: () => void
|
||||||
|
searchCenterLat?: number
|
||||||
|
searchCenterLng?: number
|
||||||
|
searchRadiusKm?: number
|
||||||
|
searchLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatchDetailHero({
|
export function MatchDetailHero({
|
||||||
@@ -32,6 +36,10 @@ export function MatchDetailHero({
|
|||||||
keyFacts,
|
keyFacts,
|
||||||
onCompare,
|
onCompare,
|
||||||
onShortlist,
|
onShortlist,
|
||||||
|
searchCenterLat,
|
||||||
|
searchCenterLng,
|
||||||
|
searchRadiusKm,
|
||||||
|
searchLabel,
|
||||||
}: MatchDetailHeroProps) {
|
}: MatchDetailHeroProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -51,6 +59,10 @@ export function MatchDetailHero({
|
|||||||
lng={property.location.coordinates.lng}
|
lng={property.location.coordinates.lng}
|
||||||
label={property.title}
|
label={property.title}
|
||||||
height={340}
|
height={340}
|
||||||
|
searchCenterLat={searchCenterLat}
|
||||||
|
searchCenterLng={searchCenterLng}
|
||||||
|
searchRadiusKm={searchRadiusKm}
|
||||||
|
searchLabel={searchLabel}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
import { Building2, Clock, ExternalLink, HardHat, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||||
import { useMatchDetail } from '../../hooks/useMatches'
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||||
|
|
||||||
@@ -61,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.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.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.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>
|
</Paper>
|
||||||
|
|
||||||
{/* Eigenschaften */}
|
{/* Eigenschaften */}
|
||||||
@@ -132,6 +140,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Grundriss */}
|
||||||
|
{(() => {
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
if (matchedUnit?.floorPlanUrl) {
|
||||||
|
const lbl = matchedUnit.unitLabel
|
||||||
|
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
|
||||||
|
: FLOOR_LABEL(matchedUnit.floorLevel)
|
||||||
|
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
|
||||||
|
} else {
|
||||||
|
units.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Beschreibung */}
|
{/* Beschreibung */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
@@ -157,7 +183,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
|||||||
{property.dataQuality.lastVerifiedAt && (
|
{property.dataQuality.lastVerifiedAt && (
|
||||||
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
||||||
Zum Originalinserat
|
Zum Originalinserat
|
||||||
|
|||||||
@@ -0,0 +1,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} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,23 +1,17 @@
|
|||||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
import type { Match, NextBestAction } from '../../domain/match'
|
import type { Match, NextBestAction } from '../../domain/match'
|
||||||
|
|
||||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
|
|
||||||
HIGH: 'contained',
|
|
||||||
MEDIUM: 'outlined',
|
|
||||||
LOW: 'outlined',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
onCompare?: () => void
|
onCompare?: () => void
|
||||||
onShortlist?: () => void
|
onPipeline?: () => void
|
||||||
onReject?: () => void
|
onInquire?: () => void
|
||||||
onReview?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
|
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
|
||||||
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
||||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
||||||
)
|
)
|
||||||
@@ -26,19 +20,27 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
||||||
|
|
||||||
{engineActions.length > 0 && (
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
{onInquire && (
|
||||||
{engineActions.map((action, i) => (
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<MessageSquare size={14} />}
|
||||||
|
onClick={onInquire}
|
||||||
|
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{engineActions.length > 0 && engineActions.map((action, i) => (
|
||||||
<Box key={i}>
|
<Box key={i}>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
variant={PRIORITY_COLOR[action.priority]}
|
variant="outlined"
|
||||||
sx={
|
sx={{ justifyContent: 'flex-start' }}
|
||||||
action.priority === 'HIGH'
|
|
||||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
|
|
||||||
: { justifyContent: 'flex-start' }
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -49,14 +51,10 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Standard actions */}
|
{onPipeline && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
|
||||||
{onShortlist && (
|
Zur Pipeline hinzufügen
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
|
|
||||||
Zur Shortlist hinzufügen
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCompare && (
|
{onCompare && (
|
||||||
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
|
|||||||
Zum Vergleich hinzufügen
|
Zum Vergleich hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onReview && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
|
|
||||||
Zur Überprüfung senden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onReject && (
|
|
||||||
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
|
|
||||||
Match ablehnen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Layers,
|
Layers,
|
||||||
ShieldCheck,
|
|
||||||
Tag,
|
Tag,
|
||||||
Train,
|
Train,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ResultType } from '../../domain/enums'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import {
|
import {
|
||||||
ASSET_LABELS,
|
ASSET_LABELS,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SOURCE_LABELS,
|
SOURCE_LABELS,
|
||||||
UnitRow,
|
UnitRow,
|
||||||
} from './MatchDetailPropertyDetails'
|
} from './MatchDetailPropertyDetails'
|
||||||
|
import { FloorPlanSection } from './FloorPlanSection'
|
||||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||||
|
|
||||||
interface PropertyDetailPublicSectionsProps {
|
interface PropertyDetailPublicSectionsProps {
|
||||||
@@ -238,6 +239,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Grundriss ── */}
|
||||||
|
{(() => {
|
||||||
|
const allUnits = property.units ?? []
|
||||||
|
const plans: Array<{ url: string; label?: string }> = []
|
||||||
|
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
|
||||||
|
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||||
|
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||||
|
})
|
||||||
|
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||||
|
return <FloorPlanSection plans={plans} mb={2} />
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── Beschreibung ── */}
|
{/* ── Beschreibung ── */}
|
||||||
{property.description && (
|
{property.description && (
|
||||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||||
@@ -270,7 +283,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
|||||||
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{property.sourceUrl && (
|
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||||
<Box sx={{ mt: 1.25 }}>
|
<Box sx={{ mt: 1.25 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
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 { Match } from '../../domain/match'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
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: <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: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
|
||||||
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '–' },
|
{ 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 (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
||||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||||
|
export { MatchDetailHero } from './MatchDetailHero'
|
||||||
|
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
|
||||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||||
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
||||||
@@ -10,4 +12,5 @@ export { MissingInformationPanel } from './MissingInformationPanel'
|
|||||||
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
||||||
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
||||||
export { NextActionsPanel } from './NextActionsPanel'
|
export { NextActionsPanel } from './NextActionsPanel'
|
||||||
|
export { FitOutCostPanel } from './FitOutCostPanel'
|
||||||
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
|
|||||||
<TextField
|
<TextField
|
||||||
label="Fläche (m²)" value={areaSqm}
|
label="Fläche (m²)" value={areaSqm}
|
||||||
onChange={e => onAreaSqmChange(e.target.value)}
|
onChange={e => onAreaSqmChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
floorPlanUrl: string
|
||||||
|
onFloorPlanUrlChange: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||||
|
URL zu einem Grundrissbild — hilft Interessenten, sich die Fläche vorzustellen.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Grundriss-URL"
|
||||||
|
value={floorPlanUrl}
|
||||||
|
onChange={e => onFloorPlanUrlChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
{floorPlanUrl && (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={floorPlanUrl}
|
||||||
|
alt="Grundriss Vorschau"
|
||||||
|
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,6 +10,12 @@ interface Props {
|
|||||||
onParkingChange: (v: string) => void
|
onParkingChange: (v: string) => void
|
||||||
ceilingHeight: string
|
ceilingHeight: string
|
||||||
onCeilingHeightChange: (v: string) => void
|
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({
|
export function TechnicalDetailsSection({
|
||||||
@@ -17,6 +23,9 @@ export function TechnicalDetailsSection({
|
|||||||
fitOut, onFitOutChange,
|
fitOut, onFitOutChange,
|
||||||
parking, onParkingChange,
|
parking, onParkingChange,
|
||||||
ceilingHeight, onCeilingHeightChange,
|
ceilingHeight, onCeilingHeightChange,
|
||||||
|
mieterausbaubeitrag, onMieterausbaubeitragChange,
|
||||||
|
isFlexible, onIsFlexibleChange,
|
||||||
|
minLettableSqm, onMinLettableSqmChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Card sx={{ p: 3, mb: 3 }}>
|
<Card sx={{ p: 3, mb: 3 }}>
|
||||||
@@ -35,17 +44,59 @@ export function TechnicalDetailsSection({
|
|||||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</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
|
<TextField
|
||||||
label="Parkplätze" value={parking}
|
label="Parkplätze" value={parking}
|
||||||
onChange={e => onParkingChange(e.target.value)}
|
onChange={e => onParkingChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||||
onChange={e => onCeilingHeightChange(e.target.value)}
|
onChange={e => onCeilingHeightChange(e.target.value)}
|
||||||
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
|
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* 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>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
|||||||
export { ImageUrlSection } from './ImageUrlSection'
|
export { ImageUrlSection } from './ImageUrlSection'
|
||||||
export { ContactSection } from './ContactSection'
|
export { ContactSection } from './ContactSection'
|
||||||
export { CreatedScreen } from './CreatedScreen'
|
export { CreatedScreen } from './CreatedScreen'
|
||||||
|
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
|
|||||||
open={open}
|
open={open}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
disableEscapeKeyDown
|
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||||
{SLIDES[activeStep]}
|
{SLIDES[activeStep]}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||||
import { useDraggable } from '@dnd-kit/core'
|
import { useDraggable } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
|
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
|
||||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||||
import { HeatBadge } from '../shared'
|
import { HeatBadge } from '../shared'
|
||||||
import type { PipelineItem } from '../../domain/pipeline'
|
import type { PipelineItem } from '../../domain/pipeline'
|
||||||
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
import { detailPath } from './pipelineUtils'
|
import { detailPath } from './pipelineUtils'
|
||||||
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||||
|
|
||||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ export function DraggableCard({
|
|||||||
onChatClick?: (e: React.MouseEvent) => void
|
onChatClick?: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
||||||
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
|
||||||
const path = detailPath(item)
|
const path = detailPath(item)
|
||||||
|
|
||||||
const style = !isDragOverlay ? {
|
const style = !isDragOverlay ? {
|
||||||
@@ -87,7 +89,7 @@ export function DraggableCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{path && (
|
{path && (
|
||||||
<Tooltip title="Objekt öffnen">
|
<Tooltip title="Match öffnen">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||||
@@ -135,6 +137,57 @@ export function DraggableCard({
|
|||||||
{item.notes}
|
{item.notes}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 6: Anfrage / Konversation */}
|
||||||
|
{!isDragOverlay && (
|
||||||
|
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
|
||||||
|
{item.stage !== 'SAVED' || item.inquiryId ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<MessagesSquare size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
borderColor: '#1e3a5f', color: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zur Konversation
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Send size={11} />}
|
||||||
|
fullWidth
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openInquiryDialog({
|
||||||
|
propertyTitle: item.title,
|
||||||
|
location: item.propertyAddress ?? item.location,
|
||||||
|
areaLabel: item.areaLabel,
|
||||||
|
rentLabel: item.rentLabel,
|
||||||
|
matchScore: item.matchScore,
|
||||||
|
pipelineItemId: item.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||||
|
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Anfrage senden
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function DroppableColumn({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
|
onCardChatClick,
|
||||||
isOver,
|
isOver,
|
||||||
}: {
|
}: {
|
||||||
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
||||||
@@ -18,6 +19,7 @@ export function DroppableColumn({
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (item: PipelineItem) => void
|
onSelect: (item: PipelineItem) => void
|
||||||
onChatClick: (inquiryId: string) => void
|
onChatClick: (inquiryId: string) => void
|
||||||
|
onCardChatClick?: (item: PipelineItem) => void
|
||||||
isOver: boolean
|
isOver: boolean
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef } = useDroppable({ id: stage.key })
|
const { setNodeRef } = useDroppable({ id: stage.key })
|
||||||
@@ -43,7 +45,13 @@ export function DroppableColumn({
|
|||||||
item={item}
|
item={item}
|
||||||
isSelected={item.id === selectedId}
|
isSelected={item.id === selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
onChatClick={
|
||||||
|
item.inquiryId
|
||||||
|
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
|
||||||
|
: onCardChatClick
|
||||||
|
? (e) => { e.stopPropagation(); onCardChatClick(item) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { STAGES, NEXT_STAGE, MOCK_DOCS, RESULT_TYPE_META } from './pipelineConstants'
|
||||||
|
export { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
|
||||||
|
export { DraggableCard } from './PipelineCard'
|
||||||
|
export { DroppableColumn } from './PipelineColumn'
|
||||||
|
export { DetailPanel } from './PipelineDetailPanel'
|
||||||
@@ -4,9 +4,8 @@ export { RESULT_TYPE_META } from '../../lib/ds'
|
|||||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const STAGES = [
|
export const STAGES = [
|
||||||
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
|
{ key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' },
|
||||||
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
|
{ key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||||
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
|
|
||||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||||
@@ -14,9 +13,8 @@ export const STAGES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||||
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
|
SAVED: { key: 'CONTACTED', label: 'Anfrage senden' },
|
||||||
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
|
CONTACTED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||||
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
|
|
||||||
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
||||||
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type { PipelineItem } from '../../domain/pipeline'
|
|||||||
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
||||||
|
|
||||||
export function detailPath(item: PipelineItem): string | null {
|
export function detailPath(item: PipelineItem): string | null {
|
||||||
// propertyId is always stable across sessions — prefer it
|
// Both 'match-XXX' (static mock) and 'm__...' (deterministic dynamic) IDs are stable across sessions
|
||||||
|
const isStableMatchId = item.matchId?.startsWith('match-') || item.matchId?.startsWith('m__')
|
||||||
|
if (isStableMatchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
||||||
// matchId / UUID only works in the same session (matchStore is ephemeral)
|
|
||||||
if (item.matchId) return `/demand/results/${item.matchId}`
|
if (item.matchId) return `/demand/results/${item.matchId}`
|
||||||
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router'
|
|||||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
|
import { resolveImageLabel } from '../../lib/propertyImageResolver'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
@@ -97,6 +98,17 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
? result.property.location.city
|
? result.property.location.city
|
||||||
: result.signal.locationHint ?? undefined
|
: result.signal.locationHint ?? undefined
|
||||||
|
|
||||||
|
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? resolveImageLabel({
|
||||||
|
assetType: result.property.assetType,
|
||||||
|
city: result.property.location.city,
|
||||||
|
district: result.property.location.district,
|
||||||
|
prestige: result.property.softFactors?.prestige,
|
||||||
|
floorLevel: result.property.floorLevel,
|
||||||
|
propertyId: result.property.id,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntelligenceMatchCard
|
<IntelligenceMatchCard
|
||||||
vm={vm}
|
vm={vm}
|
||||||
@@ -104,9 +116,25 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
lat={lat}
|
lat={lat}
|
||||||
lng={lng}
|
lng={lng}
|
||||||
cityLabel={cityLabel}
|
cityLabel={cityLabel}
|
||||||
|
overlayTypeLabel={overlayLabels?.type}
|
||||||
|
overlayLocationLabel={overlayLabels?.location}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <MatchCardCompact vm={vm} />
|
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? result.property.images?.[0]
|
||||||
|
: undefined
|
||||||
|
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
|
? resolveImageLabel({
|
||||||
|
assetType: result.property.assetType,
|
||||||
|
city: result.property.location.city,
|
||||||
|
district: result.property.location.district,
|
||||||
|
prestige: result.property.softFactors?.prestige,
|
||||||
|
floorLevel: result.property.floorLevel,
|
||||||
|
propertyId: result.property.id,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ interface Props {
|
|||||||
address?: string
|
address?: string
|
||||||
cityLabel?: string
|
cityLabel?: string
|
||||||
height?: number
|
height?: number
|
||||||
|
overlayTypeLabel?: string
|
||||||
|
overlayLocationLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: replace placeholder with Google Maps Static API:
|
|
||||||
// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY
|
|
||||||
|
|
||||||
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
||||||
if (lat != null && lng != null) {
|
if (lat != null && lng != null) {
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||||
@@ -24,7 +23,7 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
|||||||
return 'https://www.google.com/maps'
|
return 'https://www.google.com/maps'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180 }: Props) {
|
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
const mapsUrl = buildMapsUrl(lat, lng, address)
|
const mapsUrl = buildMapsUrl(lat, lng, address)
|
||||||
const showImage = !!imageUrl && !imgError
|
const showImage = !!imageUrl && !imgError
|
||||||
@@ -60,6 +59,42 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom-left overlay: type + location */}
|
||||||
|
{(overlayTypeLabel || overlayLocationLabel) && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.75,
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.58) 0%, rgba(0,0,0,0) 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel && (
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.68rem', fontWeight: 700, color: 'white',
|
||||||
|
bgcolor: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(4px)',
|
||||||
|
px: 0.75, py: 0.25, borderRadius: 0.75, lineHeight: 1.4,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overlayTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{overlayLocationLabel && (
|
||||||
|
<Typography sx={{ fontSize: '0.68rem', color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.4 }}>
|
||||||
|
{overlayLocationLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Google Maps button */}
|
{/* Google Maps button */}
|
||||||
<Tooltip title="In Google Maps öffnen" placement="top">
|
<Tooltip title="In Google Maps öffnen" placement="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Box } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
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,
|
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 {
|
interface Props {
|
||||||
lat: number
|
lat: number
|
||||||
lng: number
|
lng: number
|
||||||
label?: string
|
label?: string
|
||||||
height?: number
|
height?: number
|
||||||
|
searchCenterLat?: number
|
||||||
|
searchCenterLng?: number
|
||||||
|
searchRadiusKm?: number
|
||||||
|
searchLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
||||||
@@ -27,34 +37,85 @@ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
|||||||
return null
|
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 (
|
return (
|
||||||
<Box
|
<Box sx={{ position: 'relative', height, width: '100%', overflow: 'hidden', flexShrink: 0,
|
||||||
sx={{
|
|
||||||
height,
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flexShrink: 0,
|
|
||||||
// Ensure leaflet controls appear above MUI elements
|
|
||||||
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
||||||
'& .leaflet-control': { zIndex: 1 },
|
'& .leaflet-control': { zIndex: 1 },
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={[lat, lng]}
|
center={[lat, lng]}
|
||||||
zoom={14}
|
zoom={hasSearch ? 10 : 14}
|
||||||
scrollWheelZoom
|
scrollWheelZoom
|
||||||
style={{ height: '100%', width: '100%' }}
|
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
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
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]}>
|
<Marker position={[lat, lng]}>
|
||||||
{label && <Popup>{label}</Popup>}
|
{label && <Popup>{label}</Popup>}
|
||||||
</Marker>
|
</Marker>
|
||||||
</MapContainer>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function ShortlistDetail({ shortlist }: Props) {
|
|||||||
matchId: item.resultId,
|
matchId: item.resultId,
|
||||||
needId: '',
|
needId: '',
|
||||||
matchScore: item.matchScore,
|
matchScore: item.matchScore,
|
||||||
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK',
|
resultType: item.resultType as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||||
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
|
property: { id: item.propertyId ?? item.resultId, title: item.title } as any,
|
||||||
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
|
match: { id: item.resultId, matchScore: item.matchScore, confidenceLevel: item.confidenceScore ?? 0.8 } as any,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type { ShortlistItem } from '../../domain/shortlist'
|
|||||||
|
|
||||||
const RESULT_TYPE_LABEL: Record<string, string> = {
|
const RESULT_TYPE_LABEL: Record<string, string> = {
|
||||||
VERIFIED_PORTFOLIO: 'Portfolio',
|
VERIFIED_PORTFOLIO: 'Portfolio',
|
||||||
EXTERNAL_MARKET: 'Direkt',
|
|
||||||
MAISON_WORK: 'Maison Work',
|
MAISON_WORK: 'Maison Work',
|
||||||
FUTURE_AVAILABILITY: 'Signal',
|
FUTURE_AVAILABILITY: 'Signal',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp
|
|||||||
onClose={ready ? onClose : undefined}
|
onClose={ready ? onClose : undefined}
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
|
slotProps={{ paper: { sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } } }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||||
|
import { getKpisForRadius } from '../../domain/marketReport'
|
||||||
|
import type { MarketKpis } from '../../domain/marketReport'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
baseKpis: MarketKpis
|
||||||
|
assetTypeLabel: string
|
||||||
|
radius: number
|
||||||
|
onRadiusChange: (r: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RADII = [5, 10, 15, 20]
|
||||||
|
|
||||||
|
function vacancyColor(pct: number): string {
|
||||||
|
if (pct < 4) return '#1a7a4a'
|
||||||
|
if (pct < 8) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function vacancyLabel(pct: number): string {
|
||||||
|
if (pct < 4) return 'Engpassmarkt'
|
||||||
|
if (pct < 8) return 'Ausgewogen'
|
||||||
|
return 'Überangebot'
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchesColor(count: number): string {
|
||||||
|
if (count >= 15) return '#1a7a4a'
|
||||||
|
if (count >= 7) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchesLabel(count: number): string {
|
||||||
|
if (count >= 15) return 'Hohe Nachfrage'
|
||||||
|
if (count >= 7) return 'Moderate Nachfrage'
|
||||||
|
return 'Geringe Nachfrage'
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationColor(months: number): string {
|
||||||
|
if (months < 4) return '#1a7a4a'
|
||||||
|
if (months < 7) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(months: number): string {
|
||||||
|
if (months < 4) return 'Schnell absorbiert'
|
||||||
|
if (months < 7) return 'Moderat'
|
||||||
|
return 'Träger Markt'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioColor(ratio: number): string {
|
||||||
|
if (ratio >= 2) return '#1a7a4a'
|
||||||
|
if (ratio >= 1) return '#d97706'
|
||||||
|
return '#dc2626'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioLabel(ratio: number): string {
|
||||||
|
if (ratio >= 2) return 'Nachfrageüberhang'
|
||||||
|
if (ratio >= 1) return 'Leicht ausgeglichen'
|
||||||
|
return 'Angebotsüberhang'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
badge: string
|
||||||
|
badgeColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ label, value, badge, badgeColor }: KpiCardProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
p: 1.5,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 500, fontSize: '0.7rem', lineHeight: 1.2 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, color: '#0f172a', lineHeight: 1 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 0.75,
|
||||||
|
bgcolor: `${badgeColor}18`,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
mt: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: badgeColor, lineHeight: 1.4 }}>
|
||||||
|
{badge}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketKpiPanel({ baseKpis, assetTypeLabel, radius, onRadiusChange }: Props) {
|
||||||
|
const kpis = useMemo(
|
||||||
|
() => radius === 5 ? baseKpis : getKpisForRadius(baseKpis, radius),
|
||||||
|
[baseKpis, radius]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 2.5,
|
||||||
|
p: 1.75,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: '#f8fafc',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.8125rem' }}>
|
||||||
|
Marktumfeld — {assetTypeLabel}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||||
|
Vergleichbare Flächen im Umkreis
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={radius}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, v) => v !== null && onRadiusChange(v)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
'& .MuiToggleButton-root': {
|
||||||
|
py: 0.25,
|
||||||
|
px: 0.875,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
color: '#64748b',
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: '#1e3a5f',
|
||||||
|
color: '#ffffff',
|
||||||
|
borderColor: '#1e3a5f',
|
||||||
|
'&:hover': { bgcolor: '#1e3a5f' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RADII.map(r => (
|
||||||
|
<ToggleButton key={r} value={r}>
|
||||||
|
{r} km
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<KpiCard
|
||||||
|
label="Leerstandsquote"
|
||||||
|
value={`${kpis.vacancyRatePct.toFixed(1)} %`}
|
||||||
|
badge={vacancyLabel(kpis.vacancyRatePct)}
|
||||||
|
badgeColor={vacancyColor(kpis.vacancyRatePct)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Aktive Suchanfragen"
|
||||||
|
value={String(kpis.activePlatformSearches)}
|
||||||
|
badge={searchesLabel(kpis.activePlatformSearches)}
|
||||||
|
badgeColor={searchesColor(kpis.activePlatformSearches)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Ø Leerstandsdauer"
|
||||||
|
value={`${kpis.avgVacancyMonths.toFixed(1)} Mo.`}
|
||||||
|
badge={durationLabel(kpis.avgVacancyMonths)}
|
||||||
|
badgeColor={durationColor(kpis.avgVacancyMonths)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Nachfrage / Angebot"
|
||||||
|
value={`${kpis.demandSupplyRatio.toFixed(1)}×`}
|
||||||
|
badge={ratioLabel(kpis.demandSupplyRatio)}
|
||||||
|
badgeColor={ratioColor(kpis.demandSupplyRatio)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', mt: 1, display: 'block' }}>
|
||||||
|
Basis: {kpis.comparableListings} vergleichbare {assetTypeLabel}-Flächen im {radius}-km-Radius · KI-gestützte Schätzung
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Box, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
|
||||||
|
import { ExternalLink, FileText } from 'lucide-react'
|
||||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||||
import { PropertyMap } from '../shared'
|
import { PropertyMap } from '../shared'
|
||||||
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||||
@@ -97,8 +98,32 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
|||||||
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
|
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, mb: 0.5 }}>
|
||||||
|
<FileText size={13} color="#64748b" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
Mietvertrag
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||||
|
<TextField
|
||||||
|
label="Vertrag URL"
|
||||||
|
size="small"
|
||||||
|
value={draft.leaseContractUrl ?? p.leaseContractUrl ?? ''}
|
||||||
|
onChange={e => onDraftChange({ ...draft, leaseContractUrl: e.target.value })}
|
||||||
|
placeholder="https://…"
|
||||||
|
slotProps={{ input: { startAdornment: <ExternalLink size={12} style={{ marginRight: 6, color: '#94a3b8', flexShrink: 0 }} /> } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Bezeichnung (optional)"
|
||||||
|
size="small"
|
||||||
|
value={draft.leaseContractName ?? p.leaseContractName ?? ''}
|
||||||
|
onChange={e => onDraftChange({ ...draft, leaseContractName: e.target.value })}
|
||||||
|
placeholder="z.B. Mietvertrag 2021–2026"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<FieldGrid>
|
<FieldGrid>
|
||||||
<Field label="Aktueller Mieter" value={p.currentTenant} />
|
<Field label="Aktueller Mieter" value={p.currentTenant} />
|
||||||
<Field label="Mietlaufzeit" value={p.leaseTerm} />
|
<Field label="Mietlaufzeit" value={p.leaseTerm} />
|
||||||
@@ -109,6 +134,39 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
|||||||
<Field label="Importiert aus" value={p.importedFrom} />
|
<Field label="Importiert aus" value={p.importedFrom} />
|
||||||
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
||||||
</FieldGrid>
|
</FieldGrid>
|
||||||
|
|
||||||
|
{/* Mietvertrag */}
|
||||||
|
<Box sx={{ mt: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: p.leaseContractUrl ? '#f8fafc' : '#fafafa' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: p.leaseContractUrl ? 0.75 : 0 }}>
|
||||||
|
<FileText size={13} color={p.leaseContractUrl ? '#1e3a5f' : '#94a3b8'} />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: p.leaseContractUrl ? '#1e3a5f' : '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
Mietvertrag
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{p.leaseContractUrl ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500 }} noWrap>
|
||||||
|
{p.leaseContractName ?? 'Mietvertrag'}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<ExternalLink size={11} />}
|
||||||
|
href={p.leaseContractUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.25, px: 1, borderColor: '#cbd5e1', color: '#1e3a5f', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||||
|
Noch kein Mietvertrag hinterlegt — im Bearbeitungsmodus hinzufügen.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Floor / unit structure */}
|
{/* Floor / unit structure */}
|
||||||
@@ -121,15 +179,59 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
|||||||
|
|
||||||
{/* Object details */}
|
{/* Object details */}
|
||||||
<SectionTitle title="Objekt & Lage" />
|
<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>
|
<FieldGrid>
|
||||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
<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="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||||
</FieldGrid>
|
</FieldGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
{p.location.coordinates && (
|
{p.location.coordinates && (
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
|
|||||||
interface PropertyDetailViewProps {
|
interface PropertyDetailViewProps {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
|
hideTabs?: Array<'Matchability' | 'Marktsignale'>
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
|
const ALL_TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
|
||||||
|
|
||||||
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
|
export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: PropertyDetailViewProps) {
|
||||||
|
const TABS = ALL_TABS.filter(t => !hideTabs.includes(t as 'Matchability' | 'Marktsignale'))
|
||||||
const [tab, setTab] = useState(0)
|
const [tab, setTab] = useState(0)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [draft, setDraft] = useState<UpdatePropertyInput>({})
|
const [draft, setDraft] = useState<UpdatePropertyInput>({})
|
||||||
@@ -175,8 +177,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', p: tab === 0 ? 2.5 : 0 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', p: TABS[tab] === 'Übersicht' ? 2.5 : 0 }}>
|
||||||
{tab === 0 && (
|
{TABS[tab] === 'Übersicht' && (
|
||||||
<PropertyDetailOverview
|
<PropertyDetailOverview
|
||||||
p={property}
|
p={property}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -184,8 +186,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
|||||||
onDraftChange={setDraft}
|
onDraftChange={setDraft}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 1 && <MatchabilityTabPanel propertyId={propertyId} />}
|
{TABS[tab] === 'Matchability' && <MatchabilityTabPanel propertyId={propertyId} />}
|
||||||
{tab === 2 && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
{TABS[tab] === 'Marktsignale' && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
import { Box, Chip, LinearProgress, Tooltip, Typography } from '@mui/material'
|
||||||
import { LocationPreview } from '../shared/LocationPreview'
|
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
|
||||||
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
@@ -9,133 +9,189 @@ interface Props {
|
|||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function availabilityBadgeColor(status: string): string {
|
function availabilityColor(status: string) {
|
||||||
if (status === 'AVAILABLE_NOW') return '#1a7a4a'
|
if (status === 'AVAILABLE_NOW') return { bg: '#dcfce7', text: '#15803d' }
|
||||||
if (status === 'AVAILABLE_SOON') return '#d97706'
|
if (status === 'AVAILABLE_SOON') return { bg: '#fef9c3', text: '#a16207' }
|
||||||
return '#64748b'
|
return { bg: '#f1f5f9', text: '#64748b' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
||||||
const confPct = Math.round(p.confidenceScore * 100)
|
const confPct = Math.round(p.confidenceScore * 100)
|
||||||
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
|
const confColor = p.confidenceScore >= 0.75 ? '#15803d' : p.confidenceScore >= 0.55 ? '#d97706' : '#dc2626'
|
||||||
|
const annualRent = Math.round(p.areaSqm * p.rentPricePerSqm / 1000)
|
||||||
|
const avail = availabilityColor(p.availabilityStatus)
|
||||||
|
const assetColor = getAssetTypeColor(p.assetType)
|
||||||
|
const hasImage = !!p.images?.[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: '1px solid #e8d5c4',
|
border: '1px solid #e2e8f0',
|
||||||
background: 'linear-gradient(160deg,#fdf8f3,#faf0e6)',
|
bgcolor: 'white',
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
transition: 'box-shadow 0.15s, transform 0.12s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: '0 6px 24px rgba(0,0,0,0.10)',
|
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||||
transform: 'translateY(-1px)',
|
transform: 'translateY(-2px)',
|
||||||
|
borderColor: '#cbd5e1',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onClick={() => onSelect(p.id)}
|
|
||||||
>
|
>
|
||||||
{/* Image zone */}
|
{/* ── Image / header ── */}
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative', height: 120, flexShrink: 0, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||||
<LocationPreview
|
{hasImage ? (
|
||||||
imageUrl={p.images?.[0]}
|
<Box
|
||||||
lat={p.location.coordinates?.lat}
|
component="img"
|
||||||
lng={p.location.coordinates?.lng}
|
src={p.images![0]}
|
||||||
address={`${p.address.street} ${p.address.houseNumber}, ${p.address.city}`}
|
alt={p.title}
|
||||||
cityLabel={p.location.city}
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
height={175}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
{/* Availability badge */}
|
<Box
|
||||||
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
|
|
||||||
<Chip
|
|
||||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: availabilityBadgeColor(p.availabilityStatus),
|
width: '100%', height: '100%',
|
||||||
color: 'white',
|
background: `linear-gradient(135deg, ${assetColor}22 0%, ${assetColor}44 100%)`,
|
||||||
fontWeight: 700,
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
fontSize: 10,
|
|
||||||
height: 20,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Typography sx={{ fontSize: '2.5rem', opacity: 0.3 }}>🏢</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Asset type chip */}
|
{/* Overlay gradient */}
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
<Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.45) 0%, transparent 55%)' }} />
|
||||||
|
|
||||||
|
{/* Top badges */}
|
||||||
|
<Box sx={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 0.5 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={getAssetTypeLabel(p.assetType)}
|
label={getAssetTypeLabel(p.assetType)}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: assetColor, color: 'white', borderRadius: 1 }}
|
||||||
bgcolor: getAssetTypeColor(p.assetType),
|
/>
|
||||||
color: 'white',
|
<Chip
|
||||||
fontWeight: 600,
|
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||||
fontSize: 10,
|
size="small"
|
||||||
height: 20,
|
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: avail.bg, color: avail.text, borderRadius: 1 }}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom: annual rent on image */}
|
||||||
|
<Box sx={{ position: 'absolute', bottom: 8, left: 10, right: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.65rem', lineHeight: 1 }}>Jahresmiete ca.</Typography>
|
||||||
|
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1.05rem', lineHeight: 1.2 }}>
|
||||||
|
CHF {annualRent}k
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||||
|
{p.rentPricePerSqm} /m²/J
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Card body */}
|
{/* ── Card body ── */}
|
||||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
|
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
|
||||||
|
{/* Title + location */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
|
||||||
{p.title}
|
{p.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, mb: 1.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.3 }}>
|
||||||
|
<MapPin size={11} color="#94a3b8" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem' }}>
|
||||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Key specs */}
|
{/* Key metrics row */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<Chip label={`${p.areaSqm} m²`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||||
<Chip label={`CHF ${p.rentPricePerSqm}/m²/Jahr`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||||
{p.contractDurationMonths && (
|
<Maximize2 size={10} color="#64748b" />
|
||||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Fläche</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||||
|
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{p.availabilityDate && (
|
||||||
|
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||||
|
<Calendar size={10} color="#64748b" />
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Ab</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||||
|
{new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{p.softFactors?.prestige != null && (
|
||||||
|
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||||
|
<TrendingUp size={10} color="#64748b" />
|
||||||
|
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Prestige</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||||
|
{p.softFactors.prestige}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Soft factors */}
|
{/* Soft factor chips */}
|
||||||
{p.softFactors && (
|
{p.softFactors && (
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
{p.softFactors.prestige != null && (
|
{p.softFactors.publicTransportMinutes != null && (
|
||||||
<Chip label={`Prestige ${p.softFactors.prestige}`} size="small"
|
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
|
||||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
|
||||||
)}
|
)}
|
||||||
{p.softFactors.accessibility != null && (
|
{p.hardFacts?.fitOut && (() => {
|
||||||
<Chip label={`ÖV ${p.softFactors.publicTransportMinutes ?? p.softFactors.accessibility}min`} size="small"
|
const FIT_OUT_META: Record<string, { label: string; tip: string }> = {
|
||||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
SHELL: { label: 'Rohbau', tip: 'Keine Einbauten — volle Ausbauinvestition durch Mieter erforderlich (Böden, Decken, Trennwände, TGA).' },
|
||||||
|
BASIC: { label: 'Grundausbau', tip: 'Grundinfrastruktur vorhanden (Böden, Beleuchtung, WCs). Ausbau für Büro/Betrieb noch nötig.' },
|
||||||
|
FULL: { label: 'Vollausbau', tip: 'Bezugsfertig ausgebaut — keine Ausbauinvestition nötig. Direkt einzugsbereit.' },
|
||||||
|
PREMIUM: { label: 'Premium-Ausbau', tip: 'Hochwertig und repräsentativ ausgebaut. Sofort bezugsfertig ohne weiteren Ausbau.' },
|
||||||
|
}
|
||||||
|
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
||||||
|
return (
|
||||||
|
<Tooltip title={meta.tip} placement="top" arrow>
|
||||||
|
<Chip label={meta.label} size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9', cursor: 'help' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{p.contractDurationMonths && (
|
||||||
|
<Tooltip title={`Mindest-Vertragslaufzeit: ${p.contractDurationMonths} Monate (${Math.round(p.contractDurationMonths / 12)} Jahre)`} placement="top" arrow>
|
||||||
|
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569', cursor: 'help' }} />
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confidence */}
|
{/* Confidence bar */}
|
||||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
<Box sx={{ mt: 'auto' }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||||
<Typography variant="caption" color="text.secondary">Datenkonfidenz</Typography>
|
<Typography sx={{ fontSize: '0.67rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Datenkonfidenz</Typography>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={confPct}
|
value={confPct}
|
||||||
sx={{
|
sx={{
|
||||||
height: 5, borderRadius: 3, bgcolor: '#e2e8f0',
|
height: 4, borderRadius: 2, bgcolor: '#f1f5f9',
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: confColor },
|
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 2 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
onClick={e => { e.stopPropagation(); onSelect(p.id) }}
|
|
||||||
sx={{ mt: 1.25, alignSelf: 'flex-end', textTransform: 'none', fontSize: '0.75rem', color: '#1e3a5f', px: 0 }}
|
|
||||||
>
|
|
||||||
Details anzeigen →
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,21 +5,33 @@ import { useMarketReport } from '../../hooks/useMarketReport'
|
|||||||
import type { SignalCategory } from '../../domain/marketReport'
|
import type { SignalCategory } from '../../domain/marketReport'
|
||||||
import { SignalCard } from './SignalCard'
|
import { SignalCard } from './SignalCard'
|
||||||
import { BerichtDialog } from './BerichtDialog'
|
import { BerichtDialog } from './BerichtDialog'
|
||||||
|
import { MarketKpiPanel } from './MarketKpiPanel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
const STATIC_SECTIONS: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||||
{ category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
|
{ category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
|
||||||
{ category: 'demand', label: 'Nachfrage 5 km', icon: <TrendingUp size={16} />, color: '#1a7a4a' },
|
|
||||||
{ category: 'supply', label: 'Angebot 5 km', icon: <BarChart2 size={16} />, color: '#b45309' },
|
|
||||||
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
|
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||||
const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
|
const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [radius, setRadius] = useState(5)
|
||||||
|
|
||||||
|
const dynamicSections: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||||
|
{ category: 'demand', label: `Nachfrage ${radius} km`, icon: <TrendingUp size={16} />, color: '#1a7a4a' },
|
||||||
|
{ category: 'supply', label: `Angebot ${radius} km`, icon: <BarChart2 size={16} />, color: '#b45309' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sectionConfig = [
|
||||||
|
STATIC_SECTIONS[0],
|
||||||
|
dynamicSections[0],
|
||||||
|
dynamicSections[1],
|
||||||
|
STATIC_SECTIONS[1],
|
||||||
|
]
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -58,7 +70,17 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
|||||||
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
|
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
SECTION_CONFIG.map(({ category, label, icon, color }) => {
|
<>
|
||||||
|
{report.kpis && (
|
||||||
|
<MarketKpiPanel
|
||||||
|
baseKpis={report.kpis}
|
||||||
|
assetTypeLabel={report.assetTypeLabel}
|
||||||
|
radius={radius}
|
||||||
|
onRadiusChange={setRadius}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sectionConfig.map(({ category, label, icon, color }) => {
|
||||||
const signals = report.signals.filter(s => s.category === category)
|
const signals = report.signals.filter(s => s.category === category)
|
||||||
if (signals.length === 0) return null
|
if (signals.length === 0) return null
|
||||||
return (
|
return (
|
||||||
@@ -77,7 +99,8 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
|||||||
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dialogOpen && (
|
{dialogOpen && (
|
||||||
|
|||||||
@@ -12,18 +12,20 @@ import {
|
|||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { X, MapPin, Calendar, DollarSign, Eye, Activity } from 'lucide-react'
|
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||||
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||||
import { ReminderStatus } from '../../domain/reminder'
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, ACTION_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
||||||
|
|
||||||
export function ReminderDetailDrawer() {
|
export function ReminderDetailDrawer() {
|
||||||
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
||||||
const { data: reminder } = useReminder(selectedId ?? '')
|
const { data: reminder } = useReminder(selectedId ?? '')
|
||||||
|
const { data: reminderProperty } = usePropertyById(reminder?.propertyId ?? '')
|
||||||
const complete = useCompleteReminder()
|
const complete = useCompleteReminder()
|
||||||
const dismiss = useDismissReminder()
|
const dismiss = useDismissReminder()
|
||||||
const snooze = useSnoozeReminder()
|
const snooze = useSnoozeReminder()
|
||||||
@@ -101,6 +103,27 @@ export function ReminderDetailDrawer() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
|
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||||
|
{reminderProperty?.leaseContractUrl ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||||
|
<FileText size={12} color="#1e3a5f" />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<ExternalLink size={10} />}
|
||||||
|
href={reminderProperty.leaseContractUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#1e3a5f' }}
|
||||||
|
>
|
||||||
|
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||||
|
<FileText size={12} color="#cbd5e1" />
|
||||||
|
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useCallback } from 'react'
|
import { useMemo, useCallback } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
import { useReminders } from '../../hooks/useReminders'
|
import { useReminders } from '../../hooks/useReminders'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
@@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard'
|
|||||||
import { ReminderSkeleton } from './ReminderSkeleton'
|
import { ReminderSkeleton } from './ReminderSkeleton'
|
||||||
import { ReminderEmptyState } from './ReminderEmptyState'
|
import { ReminderEmptyState } from './ReminderEmptyState'
|
||||||
import type { Reminder } from '../../domain/reminder'
|
import type { Reminder } from '../../domain/reminder'
|
||||||
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
|
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||||
|
|
||||||
const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
const MOCK_TODAY = new Date('2026-05-20')
|
||||||
|
|
||||||
|
function getHorizon(dueDate: string): FilterHorizon {
|
||||||
|
const days = Math.ceil((new Date(dueDate).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
if (days <= 0) return 'OVERDUE'
|
||||||
|
if (days <= 7) return 'THIS_WEEK'
|
||||||
|
if (days <= 30) return 'THIS_MONTH'
|
||||||
|
return 'LATER'
|
||||||
|
}
|
||||||
|
|
||||||
|
const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: string; border: string }[] = [
|
||||||
|
{ key: 'OVERDUE', label: 'Überfällig', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||||
|
{ key: 'THIS_WEEK', label: 'Diese Woche', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
|
||||||
|
{ key: 'THIS_MONTH', label: 'Dieser Monat', color: '#0369a1', bg: '#f0f9ff', border: '#bae6fd' },
|
||||||
|
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e2e8f0' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
||||||
|
|
||||||
function applyFilters(
|
function applyFilters(
|
||||||
reminders: Reminder[],
|
reminders: Reminder[],
|
||||||
filterType: string,
|
filterType: string,
|
||||||
filterStatus: string,
|
filterStatus: string,
|
||||||
filterPriority: string,
|
filterHorizon: string,
|
||||||
searchQuery: string,
|
searchQuery: string,
|
||||||
): Reminder[] {
|
): Reminder[] {
|
||||||
return reminders.filter(r => {
|
return reminders.filter(r => {
|
||||||
|
if (filterStatus === 'ACTIVE') {
|
||||||
|
if (r.status !== ReminderStatus.ACTIVE && r.status !== ReminderStatus.SNOOZED) return false
|
||||||
|
} else if (filterStatus !== 'ALL') {
|
||||||
|
if (r.status !== filterStatus) return false
|
||||||
|
}
|
||||||
if (filterType !== 'ALL' && r.type !== filterType) return false
|
if (filterType !== 'ALL' && r.type !== filterType) return false
|
||||||
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false
|
if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false
|
||||||
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase()
|
const q = searchQuery.toLowerCase()
|
||||||
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||||
if (!haystack.includes(q)) return false
|
if (!hay.includes(q)) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -34,38 +57,60 @@ function applyFilters(
|
|||||||
export function ReminderFeed() {
|
export function ReminderFeed() {
|
||||||
const { data, isLoading } = useReminders()
|
const { data, isLoading } = useReminders()
|
||||||
const {
|
const {
|
||||||
filterType, filterStatus, filterPriority, searchQuery, viewMode,
|
filterType, filterStatus, filterHorizon, searchQuery, viewMode,
|
||||||
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery,
|
setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery,
|
||||||
} = useReminderStore(useShallow(s => ({
|
} = useReminderStore(useShallow(s => ({
|
||||||
filterType: s.filterType, filterStatus: s.filterStatus,
|
filterType: s.filterType, filterStatus: s.filterStatus,
|
||||||
filterPriority: s.filterPriority, searchQuery: s.searchQuery,
|
filterHorizon: s.filterHorizon, searchQuery: s.searchQuery,
|
||||||
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
||||||
setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority,
|
setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon,
|
||||||
setSearchQuery: s.setSearchQuery,
|
setSearchQuery: s.setSearchQuery,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
const reminders = data ?? []
|
const reminders = data ?? []
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
|
() => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery),
|
||||||
[reminders, filterType, filterStatus, filterPriority, searchQuery],
|
[reminders, filterType, filterStatus, filterHorizon, searchQuery],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map: Record<string, Reminder[]> = {
|
||||||
|
OVERDUE: [], THIS_WEEK: [], THIS_MONTH: [], LATER: [],
|
||||||
|
}
|
||||||
|
filtered.forEach(r => map[getHorizon(r.dueDate)].push(r))
|
||||||
|
return map
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setFilterType('ALL')
|
setFilterType('ALL')
|
||||||
setFilterStatus('ALL')
|
setFilterStatus('ACTIVE')
|
||||||
setFilterPriority('ALL')
|
setFilterHorizon('ALL')
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
|
}, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery])
|
||||||
|
|
||||||
if (isLoading) return <ReminderSkeleton />
|
if (isLoading) return <ReminderSkeleton />
|
||||||
|
if (filtered.length === 0) return <ReminderEmptyState onReset={resetFilters} />
|
||||||
if (filtered.length === 0) {
|
|
||||||
return <ReminderEmptyState onReset={resetFilters} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewMode === 'card') {
|
if (viewMode === 'card') {
|
||||||
return (
|
return (
|
||||||
|
<Box className="flex flex-col gap-4">
|
||||||
|
{HORIZON_CONFIG.map(({ key, label, color }) => {
|
||||||
|
const group = grouped[key]
|
||||||
|
if (!group || group.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, px: 0.5 }}>
|
||||||
|
<Box sx={{ width: 3, height: 16, borderRadius: 1, bgcolor: color, flexShrink: 0 }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color, fontSize: '0.8125rem' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={group.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', bgcolor: color, color: 'white', ml: 0.25 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -73,20 +118,22 @@ export function ReminderFeed() {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filtered.map(r => (
|
{group.map(r => <ReminderCard key={r.id} reminder={r} />)}
|
||||||
<ReminderCard key={r.id} reminder={r} />
|
</Box>
|
||||||
))}
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: LIST_HEADER_COLS,
|
gridTemplateColumns: LIST_COLS,
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1,
|
py: 1,
|
||||||
@@ -101,9 +148,43 @@ export function ReminderFeed() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{filtered.map(r => (
|
{/* Grouped rows */}
|
||||||
<ReminderListRow key={r.id} reminder={r} />
|
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
|
||||||
))}
|
const group = grouped[key]
|
||||||
|
if (!group || group.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
{/* Section divider */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 0.625,
|
||||||
|
bgcolor: bg,
|
||||||
|
borderTop: `1px solid ${border}`,
|
||||||
|
borderBottom: `1px solid ${border}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: 0.6, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={group.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 16, fontSize: '0.6rem', bgcolor: color, color: 'white', px: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{group.map(r => <ReminderListRow key={r.id} reminder={r} />)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography } from '@mui/material'
|
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography, Select, MenuItem, FormControl, FormControlLabel, Switch } from '@mui/material'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
import { ReminderType } from '../../domain/reminder'
|
||||||
import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder'
|
import type { ReminderType as ReminderTypeType } from '../../domain/reminder'
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||||
LEASE_EXPIRY: 'Mietablauf',
|
LEASE_EXPIRY: 'Mietablauf',
|
||||||
@@ -16,38 +16,23 @@ const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
|||||||
CUSTOM: 'Individuell',
|
CUSTOM: 'Individuell',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<ReminderStatusType, string> = {
|
|
||||||
ACTIVE: 'Aktiv',
|
|
||||||
SNOOZED: 'Schlummern',
|
|
||||||
COMPLETED: 'Erledigt',
|
|
||||||
DISMISSED: 'Verworfen',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<ReminderPriorityType, string> = {
|
|
||||||
URGENT: 'Dringend',
|
|
||||||
HIGH: 'Hoch',
|
|
||||||
MEDIUM: 'Mittel',
|
|
||||||
LOW: 'Niedrig',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReminderFilterBar() {
|
export function ReminderFilterBar() {
|
||||||
const {
|
const {
|
||||||
filterType, setFilterType,
|
filterType, setFilterType,
|
||||||
filterStatus, setFilterStatus,
|
filterStatus, setFilterStatus,
|
||||||
filterPriority, setFilterPriority,
|
|
||||||
searchQuery, setSearchQuery,
|
searchQuery, setSearchQuery,
|
||||||
viewMode, setViewMode,
|
viewMode, setViewMode,
|
||||||
} = useReminderStore(useShallow(s => ({
|
} = useReminderStore(useShallow(s => ({
|
||||||
filterType: s.filterType, setFilterType: s.setFilterType,
|
filterType: s.filterType, setFilterType: s.setFilterType,
|
||||||
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
||||||
filterPriority: s.filterPriority, setFilterPriority: s.setFilterPriority,
|
|
||||||
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
||||||
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
|
const showArchive = filterStatus === 'ALL'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-3">
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
<Box className="flex items-center gap-3 flex-wrap">
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
@@ -63,7 +48,43 @@ export function ReminderFilterBar() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
sx={{ width: 200, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Type selector */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>Typ:</Typography>
|
||||||
|
<FormControl size="small">
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onChange={e => setFilterType(e.target.value as ReminderTypeType | 'ALL')}
|
||||||
|
sx={{ fontSize: '0.8125rem', minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="ALL" sx={{ fontSize: '0.8125rem' }}>Alle Typen</MenuItem>
|
||||||
|
{Object.values(ReminderType).map(t => (
|
||||||
|
<MenuItem key={t} value={t} sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{TYPE_LABELS[t]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Archive toggle */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={showArchive}
|
||||||
|
onChange={e => setFilterStatus(e.target.checked ? 'ALL' : 'ACTIVE')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Abgeschlossene anzeigen
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ m: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* View mode */}
|
{/* View mode */}
|
||||||
@@ -80,64 +101,5 @@ export function ReminderFilterBar() {
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="flex flex-wrap gap-3">
|
|
||||||
{/* Type filter */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Typ:</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterType}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setFilterType(v)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
|
||||||
{Object.values(ReminderType).map(t => (
|
|
||||||
<ToggleButton key={t} value={t} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
|
||||||
{TYPE_LABELS[t]}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="flex flex-wrap gap-3">
|
|
||||||
{/* Status filter */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Status:</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterStatus}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setFilterStatus(v)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
|
||||||
{Object.values(ReminderStatus).map(s => (
|
|
||||||
<ToggleButton key={s} value={s} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
|
||||||
{STATUS_LABELS[s]}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Priority filter */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Priorität:</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterPriority}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setFilterPriority(v)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
|
||||||
{Object.values(ReminderPriority).map(p => (
|
|
||||||
<ToggleButton key={p} value={p} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
|
||||||
{PRIORITY_LABELS[p]}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,46 @@
|
|||||||
import { Box, Paper, Typography, Skeleton } from '@mui/material'
|
import { Box, Typography, Skeleton } from '@mui/material'
|
||||||
import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react'
|
import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderInsights } from '../../hooks/useReminders'
|
import { useReminderInsights } from '../../hooks/useReminders'
|
||||||
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
|
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||||
|
import { ReminderType } from '../../domain/reminder'
|
||||||
|
|
||||||
interface KpiItemProps {
|
interface KpiCardProps {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
label: string
|
label: string
|
||||||
value: number | string
|
value: number | string
|
||||||
color: string
|
color: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Box
|
||||||
variant="outlined"
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
borderColor: '#e2e8f0',
|
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: active ? `${color}10` : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: `${color}0d`,
|
||||||
|
borderColor: color,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 36,
|
width: 34,
|
||||||
height: 36,
|
height: 34,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
bgcolor: `${color}18`,
|
bgcolor: `${color}18`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -41,16 +55,42 @@ function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
|||||||
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap', fontSize: '0.7rem' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReminderKpiBar() {
|
export function ReminderKpiBar() {
|
||||||
const { data, isLoading } = useReminderInsights()
|
const { data, isLoading } = useReminderInsights()
|
||||||
|
const { filterHorizon, setFilterHorizon, filterType, setFilterType } = useReminderStore(
|
||||||
|
useShallow(s => ({
|
||||||
|
filterHorizon: s.filterHorizon,
|
||||||
|
setFilterHorizon: s.setFilterHorizon,
|
||||||
|
filterType: s.filterType,
|
||||||
|
setFilterType: s.setFilterType,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleHorizon(h: FilterHorizon) {
|
||||||
|
if (filterHorizon === h) {
|
||||||
|
setFilterHorizon('ALL')
|
||||||
|
} else {
|
||||||
|
setFilterHorizon(h)
|
||||||
|
setFilterType('ALL') // clear type filter so count matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreMarket() {
|
||||||
|
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
|
||||||
|
setFilterType('ALL')
|
||||||
|
} else {
|
||||||
|
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
|
||||||
|
setFilterHorizon('ALL') // clear horizon filter so count matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -62,33 +102,41 @@ export function ReminderKpiBar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const insights = data ?? { urgentCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex gap-3">
|
<Box className="flex gap-3">
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<AlertTriangle size={18} color="#dc2626" />}
|
icon={<AlertOctagon size={18} color="#dc2626" />}
|
||||||
label="Dringend"
|
label="Überfällig"
|
||||||
value={insights.urgentCount}
|
value={ins.overdueCount}
|
||||||
color="#dc2626"
|
color="#dc2626"
|
||||||
|
active={filterHorizon === 'OVERDUE'}
|
||||||
|
onClick={() => toggleHorizon('OVERDUE')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<Calendar size={18} color="#ea580c" />}
|
icon={<Calendar size={18} color="#ea580c" />}
|
||||||
label="Diese Woche"
|
label="Diese Woche"
|
||||||
value={insights.dueThisWeek}
|
value={ins.dueThisWeek}
|
||||||
color="#ea580c"
|
color="#ea580c"
|
||||||
|
active={filterHorizon === 'THIS_WEEK'}
|
||||||
|
onClick={() => toggleHorizon('THIS_WEEK')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<CalendarDays size={18} color="#0369a1" />}
|
icon={<CalendarDays size={18} color="#0369a1" />}
|
||||||
label="Dieser Monat"
|
label="Dieser Monat"
|
||||||
value={insights.dueThisMonth}
|
value={ins.dueThisMonth}
|
||||||
color="#0369a1"
|
color="#0369a1"
|
||||||
|
active={filterHorizon === 'THIS_MONTH'}
|
||||||
|
onClick={() => toggleHorizon('THIS_MONTH')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<Eye size={18} color="#be185d" />}
|
icon={<Eye size={18} color="#be185d" />}
|
||||||
label="Pre-Market Risiko"
|
label="Pre-Market Risiko"
|
||||||
value={insights.schattenmarktReadyCount}
|
value={ins.schattenmarktReadyCount}
|
||||||
color="#be185d"
|
color="#be185d"
|
||||||
|
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
|
||||||
|
onClick={togglePreMarket}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
|
|||||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||||
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { ReminderStatus } from '../../domain/reminder'
|
import { ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
||||||
|
|
||||||
|
const PRIORITY_BORDER: Record<string, string> = {
|
||||||
|
[ReminderPriority.URGENT]: '#dc2626',
|
||||||
|
[ReminderPriority.HIGH]: '#ea580c',
|
||||||
|
[ReminderPriority.MEDIUM]: '#ca8a04',
|
||||||
|
[ReminderPriority.LOW]: 'transparent',
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
||||||
ACTIVE: 'info',
|
ACTIVE: 'info',
|
||||||
@@ -56,6 +63,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
|||||||
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
@@ -64,9 +73,11 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
|||||||
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2,
|
pl: 1.5,
|
||||||
|
pr: 2,
|
||||||
py: 1.25,
|
py: 1.25,
|
||||||
borderBottom: '1px solid #f1f5f9',
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
borderLeft: `3px solid ${borderColor}`,
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': { bgcolor: '#f8fafc' },
|
'&:hover': { bgcolor: '#f8fafc' },
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
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 { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
import { floorLabel } from './PropertyDetailHelpers'
|
import { floorLabel } from './PropertyDetailHelpers'
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
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])
|
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||||
|
|
||||||
@@ -114,9 +118,76 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
{u.available ? (
|
{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}` }} />
|
<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 — show when actively editing OR when teilbar is on but min sqm not set */}
|
||||||
|
{(editingFlexUnit === u.id || (u.isFlexible && !u.minLettableSqm)) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
autoFocus={!u.minLettableSqm}
|
||||||
|
placeholder="Min m² *"
|
||||||
|
value={flexDraft[u.id] ?? ''}
|
||||||
|
onChange={e => setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))}
|
||||||
|
error={!flexDraft[u.id] && u.isFlexible && !u.minLettableSqm}
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
'& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 },
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
'& fieldset': { borderColor: (!flexDraft[u.id] && u.isFlexible && !u.minLettableSqm) ? '#ef4444' : undefined },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!flexDraft[u.id]}
|
||||||
|
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>
|
||||||
|
{editingFlexUnit === u.id && u.minLettableSqm && (
|
||||||
|
<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
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -133,6 +204,14 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
|||||||
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
|
rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm,
|
||||||
unitLabel: u.unitLabel,
|
unitLabel: u.unitLabel,
|
||||||
propertyId: p.id,
|
propertyId: p.id,
|
||||||
|
floor: floorLabel(u),
|
||||||
|
fitOut: p.hardFacts?.fitOut,
|
||||||
|
parking: p.hardFacts?.parking,
|
||||||
|
ceilingHeight: p.hardFacts?.ceilingHeightM,
|
||||||
|
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
|
||||||
|
isFlexible: u.isFlexible,
|
||||||
|
minLettableSqm: u.minLettableSqm,
|
||||||
|
availableFrom: u.listing?.availableFrom ?? p.availabilityDate,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function getAssetTypeLabel(type: AssetType): string {
|
|||||||
GASTRO: 'Gastro',
|
GASTRO: 'Gastro',
|
||||||
PRODUCTION: 'Produktion',
|
PRODUCTION: 'Produktion',
|
||||||
MIXED: 'Gemischt',
|
MIXED: 'Gemischt',
|
||||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||||
UNKNOWN: 'Unbekannt',
|
UNKNOWN: 'Unbekannt',
|
||||||
}
|
}
|
||||||
return labels[type] ?? type
|
return labels[type] ?? type
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export type AssetType = typeof AssetType[keyof typeof AssetType]
|
|||||||
// ── Result Types ──────────────────────────────────────────────────────────────
|
// ── Result Types ──────────────────────────────────────────────────────────────
|
||||||
export const ResultType = {
|
export const ResultType = {
|
||||||
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
|
VERIFIED_PORTFOLIO: 'VERIFIED_PORTFOLIO',
|
||||||
EXTERNAL_MARKET: 'EXTERNAL_MARKET',
|
|
||||||
MAISON_WORK: 'MAISON_WORK',
|
MAISON_WORK: 'MAISON_WORK',
|
||||||
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
|
FUTURE_AVAILABILITY: 'FUTURE_AVAILABILITY',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
|
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
|
||||||
|
|
||||||
|
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
|
||||||
|
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
|
||||||
|
|
||||||
|
export interface ExtractedContact {
|
||||||
|
type: ContactType
|
||||||
|
value: string
|
||||||
|
confidence: ContactConfidence
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignalSource {
|
export interface SignalSource {
|
||||||
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
|
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
|
||||||
url?: string
|
url?: string
|
||||||
@@ -58,4 +68,7 @@ export interface FutureSignal {
|
|||||||
|
|
||||||
// Unit-level reference: if set, this signal is about a specific rentable unit
|
// Unit-level reference: if set, this signal is about a specific rentable unit
|
||||||
unitId?: string
|
unitId?: string
|
||||||
|
|
||||||
|
// KI-extracted contact information from web crawler sources
|
||||||
|
extractedContacts?: ExtractedContact[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export interface Inquiry {
|
|||||||
tenantName: string
|
tenantName: string
|
||||||
tenantCompany?: string
|
tenantCompany?: string
|
||||||
tenantEmail?: string
|
tenantEmail?: string
|
||||||
|
propertyManagerName?: string
|
||||||
|
propertyManagerCompany?: string
|
||||||
|
propertyAddress?: string
|
||||||
subject: string
|
subject: string
|
||||||
message: string
|
message: string
|
||||||
status?: InquiryStatus
|
status?: InquiryStatus
|
||||||
|
|||||||
@@ -11,8 +11,32 @@ export interface PropertyMarketSignal {
|
|||||||
confidence?: number
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key market performance indicators for a given radius.
|
||||||
|
// Base values are stored at 5 km; other radii are computed via getKpisForRadius().
|
||||||
|
export interface MarketKpis {
|
||||||
|
vacancyRatePct: number // % of comparable properties currently vacant
|
||||||
|
activePlatformSearches: number // active search requests on the platform
|
||||||
|
avgVacancyMonths: number // average months comparable properties remain vacant
|
||||||
|
demandSupplyRatio: number // demand / supply ratio (> 1 = more demand than supply)
|
||||||
|
comparableListings: number // number of comparable active listings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scale base KPIs (at 5 km) for a larger radius. */
|
||||||
|
export function getKpisForRadius(base: MarketKpis, radiusKm: number): MarketKpis {
|
||||||
|
const f = radiusKm / 5
|
||||||
|
return {
|
||||||
|
vacancyRatePct: +Math.min(35, base.vacancyRatePct + (f - 1) * 1.8).toFixed(1),
|
||||||
|
activePlatformSearches: Math.round(base.activePlatformSearches * f * 0.85),
|
||||||
|
avgVacancyMonths: +Math.min(24, base.avgVacancyMonths * (1 + (f - 1) * 0.12)).toFixed(1),
|
||||||
|
demandSupplyRatio: +Math.max(0.3, base.demandSupplyRatio / (1 + (f - 1) * 0.18)).toFixed(1),
|
||||||
|
comparableListings: Math.round(base.comparableListings * f * 1.1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarketReport {
|
export interface MarketReport {
|
||||||
propertyId: string
|
propertyId: string
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
|
assetTypeLabel: string // e.g. "Büro"
|
||||||
|
kpis: MarketKpis // base values at 5 km radius
|
||||||
signals: PropertyMarketSignal[]
|
signals: PropertyMarketSignal[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AssetType } from './enums'
|
||||||
|
|
||||||
|
export type HinweisVisibility = 'INTERN' | 'PLATTFORM'
|
||||||
|
export type HinweisDirection = 'SUCHE' | 'VERFUEGBARKEIT'
|
||||||
|
export type HinweisStatus = 'OFFEN' | 'IN_BEARBEITUNG' | 'ARCHIVIERT'
|
||||||
|
export type HinweisQuelle = 'NETZWERKEVENT' | 'TELEFONAT' | 'BESICHTIGUNG' | 'MESSE' | 'EMAIL' | 'SONSTIGES'
|
||||||
|
|
||||||
|
export interface MarktHinweis {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
createdBy: string
|
||||||
|
verwaltungId: string
|
||||||
|
verwaltungName: string
|
||||||
|
direction: HinweisDirection
|
||||||
|
assetType: AssetType
|
||||||
|
locationHint: string
|
||||||
|
areaSqmMin?: number
|
||||||
|
areaSqmMax?: number
|
||||||
|
companyName?: string
|
||||||
|
isAnonymized: boolean
|
||||||
|
quelle: HinweisQuelle
|
||||||
|
note?: string
|
||||||
|
visibility: HinweisVisibility
|
||||||
|
status: HinweisStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateMarktHinweisInput = Omit<MarktHinweis, 'id' | 'createdAt' | 'createdBy' | 'verwaltungId' | 'verwaltungName'>
|
||||||
@@ -92,6 +92,11 @@ export interface Need {
|
|||||||
requireBarrierFree?: boolean
|
requireBarrierFree?: boolean
|
||||||
minCeilingHeightM?: number
|
minCeilingHeightM?: number
|
||||||
minContractDurationMonths?: number
|
minContractDurationMonths?: number
|
||||||
|
searchRadius?: number
|
||||||
|
isAnonymous?: boolean
|
||||||
|
requiresDivisibility?: boolean
|
||||||
|
minDivisibleUnit?: number
|
||||||
|
fitOutBudgetMaxPerSqm?: number
|
||||||
|
|
||||||
softFactors?: SoftFactorPreferences
|
softFactors?: SoftFactorPreferences
|
||||||
weightingProfile: WeightingProfile
|
weightingProfile: WeightingProfile
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export interface ParsedNeedCriteria {
|
|||||||
footfallNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
|
footfallNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
companyName?: string
|
companyName?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
|
searchRadius?: number
|
||||||
|
isAnonymous?: boolean
|
||||||
|
requiresDivisibility?: boolean
|
||||||
|
minDivisibleUnit?: number
|
||||||
|
fitOutBudgetMaxPerSqm?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FollowUpQuestion {
|
export interface FollowUpQuestion {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
export type PipelineStage = 'SAVED' | 'DISCOVERED' | 'QUALIFIED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
|
export type PipelineStage = 'SAVED' | 'CONTACTED' | 'VISITED' | 'NEGOTIATION' | 'CLOSED_WON' | 'CLOSED_LOST'
|
||||||
|
|
||||||
export interface PipelineItem {
|
export interface PipelineItem {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
location: string
|
location: string
|
||||||
matchScore: number
|
matchScore: number
|
||||||
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
||||||
stage: PipelineStage
|
stage: PipelineStage
|
||||||
// Source match reference — used for navigating back to the detail page
|
// Source match reference — used for navigating back to the detail page
|
||||||
matchId?: string
|
matchId?: string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
||||||
} from './enums'
|
} from './enums'
|
||||||
import { AvailabilityStatus as AS } from './enums'
|
import { AvailabilityStatus as AS } from './enums'
|
||||||
|
import type { PropertyUnit } from './unit'
|
||||||
|
|
||||||
// Re-export unit types so all existing imports from 'property' continue to work
|
// Re-export unit types so all existing imports from 'property' continue to work
|
||||||
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
||||||
@@ -53,6 +54,7 @@ export interface DataQuality {
|
|||||||
export interface PropertyHardFacts {
|
export interface PropertyHardFacts {
|
||||||
floor?: number
|
floor?: number
|
||||||
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
|
||||||
|
mieterausbaubeitragPerSqm?: number
|
||||||
parking?: number
|
parking?: number
|
||||||
publicTransportScore?: number
|
publicTransportScore?: number
|
||||||
usageType?: string
|
usageType?: string
|
||||||
@@ -134,6 +136,9 @@ export interface Property {
|
|||||||
riskLevel?: RiskLevel
|
riskLevel?: RiskLevel
|
||||||
description?: string
|
description?: string
|
||||||
images?: string[]
|
images?: string[]
|
||||||
|
floorPlanUrl?: string
|
||||||
|
leaseContractUrl?: string // link to the signed lease document
|
||||||
|
leaseContractName?: string // display label, e.g. "Mietvertrag 2021–2026"
|
||||||
|
|
||||||
propertyNumber?: string
|
propertyNumber?: string
|
||||||
units?: PropertyUnit[]
|
units?: PropertyUnit[]
|
||||||
@@ -149,7 +154,7 @@ export interface Property {
|
|||||||
importedAt?: string
|
importedAt?: string
|
||||||
lastUpdatedAt?: string
|
lastUpdatedAt?: string
|
||||||
|
|
||||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number }
|
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
|
||||||
|
|
||||||
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
||||||
lastReviewedAt?: string
|
lastReviewedAt?: string
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const DATA_QUALITY_MODIFIER = {
|
|||||||
export const CONFIDENCE_MODIFIER = {
|
export const CONFIDENCE_MODIFIER = {
|
||||||
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
|
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
|
||||||
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
|
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
|
||||||
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
|
|
||||||
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
|
MAISON_WORK: -2, // MAISON_WORK — Maison Work API (slightly more trusted than scraped market)
|
||||||
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
|
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
|
||||||
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
|
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
|
||||||
|
|||||||
@@ -15,14 +15,7 @@ interface UnifiedResultBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
export interface VerifiedPortfolioResult extends UnifiedResultBase {
|
||||||
resultType: 'VERIFIED_PORTFOLIO'
|
resultType: 'VERIFIED_PORTFOLIO' | 'MAISON_WORK'
|
||||||
property: Property
|
|
||||||
match: Match
|
|
||||||
unit?: PropertyUnit // specific unit this match refers to
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalMarketResult extends UnifiedResultBase {
|
|
||||||
resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK'
|
|
||||||
property: Property
|
property: Property
|
||||||
match: Match
|
match: Match
|
||||||
unit?: PropertyUnit // specific unit this match refers to
|
unit?: PropertyUnit // specific unit this match refers to
|
||||||
@@ -36,7 +29,9 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
|||||||
unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
|
unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Alias kept for backward compatibility — use VerifiedPortfolioResult */
|
||||||
|
export type ExternalMarketResult = VerifiedPortfolioResult
|
||||||
|
|
||||||
export type UnifiedMatchResult =
|
export type UnifiedMatchResult =
|
||||||
| VerifiedPortfolioResult
|
| VerifiedPortfolioResult
|
||||||
| ExternalMarketResult
|
|
||||||
| FutureAvailabilityResult
|
| FutureAvailabilityResult
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PropertyUnit {
|
|||||||
currentTenant?: string
|
currentTenant?: string
|
||||||
leaseTerm?: string
|
leaseTerm?: string
|
||||||
leaseEndDate?: string
|
leaseEndDate?: string
|
||||||
|
floorPlanUrl?: string // optional floor plan image
|
||||||
// Flexible letting (Teilfläche)
|
// Flexible letting (Teilfläche)
|
||||||
isFlexible?: boolean
|
isFlexible?: boolean
|
||||||
minLettableSqm?: number
|
minLettableSqm?: number
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
|||||||
|
|
||||||
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
||||||
const vm = buildMatchCardViewModel(result, [])
|
const vm = buildMatchCardViewModel(result, [])
|
||||||
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
expect(vm.scoreBreakdown!.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||||
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
expect(vm.scoreBreakdown!.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||||
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore)
|
expect(vm.scoreBreakdown!.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does NOT set a disclaimer for verified portfolio results', () => {
|
it('does NOT set a disclaimer for verified portfolio results', () => {
|
||||||
@@ -65,7 +65,7 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('passes through supplied actions unchanged', () => {
|
it('passes through supplied actions unchanged', () => {
|
||||||
const actions = [{ label: 'Shortlist', actionType: 'SHORTLIST' as const, primary: true }]
|
const actions = [{ id: 'shortlist', label: 'Shortlist', actionType: 'SAVE_SHORTLIST' as const, primary: true, onClick: () => {} }]
|
||||||
const vm = buildMatchCardViewModel(result, actions)
|
const vm = buildMatchCardViewModel(result, actions)
|
||||||
expect(vm.actions).toBe(actions)
|
expect(vm.actions).toBe(actions)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,20 +38,17 @@ describe('rankMatches', () => {
|
|||||||
expect(ranked[0].propertyId).toBe('high')
|
expect(ranked[0].propertyId).toBe('high')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('breaks ties by result type: VERIFIED_PORTFOLIO before EXTERNAL_MARKET', () => {
|
it('breaks ties by result type: VERIFIED_PORTFOLIO and MAISON_WORK ranked above FUTURE_AVAILABILITY', () => {
|
||||||
// Build one of each type but force identical score by using same property body
|
// Build one of each type but force identical score by using same property body
|
||||||
const prop = makeProperty()
|
const prop = makeProperty()
|
||||||
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
|
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||||
const external = buildFullMatch(need, { ...prop, id: 'e', resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.85 })
|
const future = buildFullMatch(need, { ...prop, id: 'f', resultType: ResultType.FUTURE_AVAILABILITY, confidenceScore: 0.85 })
|
||||||
|
|
||||||
// If scores differ, force them equal for a clean tiebreak test
|
// Force same score for a clean tiebreak test
|
||||||
if (verified.matchScore !== external.matchScore) {
|
verified.matchScore = 75
|
||||||
const lower = Math.min(verified.matchScore, external.matchScore)
|
future.matchScore = 75
|
||||||
verified.matchScore = lower
|
|
||||||
external.matchScore = lower
|
|
||||||
}
|
|
||||||
|
|
||||||
const ranked = rankMatches([external, verified])
|
const ranked = rankMatches([future, verified])
|
||||||
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ describe('calcConfidenceModifier', () => {
|
|||||||
expect(calcConfidenceModifier(p)).toBe(0)
|
expect(calcConfidenceModifier(p)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns -3 for external market results', () => {
|
|
||||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.80 })
|
|
||||||
expect(calcConfidenceModifier(p)).toBe(-3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns -2 for Maison Work results', () => {
|
it('returns -2 for Maison Work results', () => {
|
||||||
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
||||||
expect(calcConfidenceModifier(p)).toBe(-2)
|
expect(calcConfidenceModifier(p)).toBe(-2)
|
||||||
@@ -72,9 +67,9 @@ describe('calcConfidenceModifier', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
||||||
// EXTERNAL_MARKET (-3) + LOW_CONFIDENCE (-10) = -13
|
// MAISON_WORK (-2) + LOW_CONFIDENCE (-10) = -12
|
||||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.45 })
|
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.45 })
|
||||||
expect(calcConfidenceModifier(p)).toBe(-13)
|
expect(calcConfidenceModifier(p)).toBe(-12)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
||||||
@@ -157,12 +152,12 @@ describe('calculateScore', () => {
|
|||||||
expect(output.finalScore).toBeGreaterThanOrEqual(85)
|
expect(output.finalScore).toBeGreaterThanOrEqual(85)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('scores a weak match (wrong city, over budget, poor DQ, external market) below 50', () => {
|
it('scores a weak match (wrong city, over budget, poor DQ, low confidence) below 50', () => {
|
||||||
const need = makeNeed({ preferredLocations: ['Zürich'] })
|
const need = makeNeed({ preferredLocations: ['Zürich'] })
|
||||||
const prop = makeProperty({
|
const prop = makeProperty({
|
||||||
location: { city: 'Basel', country: 'CH' },
|
location: { city: 'Basel', country: 'CH' },
|
||||||
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
|
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
|
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
|
||||||
dataQuality: {
|
dataQuality: {
|
||||||
score: 0.30, // CRITICAL → -15
|
score: 0.30, // CRITICAL → -15
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ describe('softFactorEnrichmentService — score range invariant (all keys, 0–1
|
|||||||
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
||||||
it('returns identical estimate for same location regardless of resultType', () => {
|
it('returns identical estimate for same location regardless of resultType', () => {
|
||||||
// The enrichment service is location-only — resultType is irrelevant.
|
// The enrichment service is location-only — resultType is irrelevant.
|
||||||
// FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score.
|
// FUTURE_AVAILABILITY (pre-market) and VERIFIED_PORTFOLIO should produce the same soft factor score.
|
||||||
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
|
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
|
||||||
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
|
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
|
||||||
|
|
||||||
@@ -141,16 +141,16 @@ describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
|
|||||||
address: sharedAddress,
|
address: sharedAddress,
|
||||||
resultType: ResultType.FUTURE_AVAILABILITY,
|
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||||
})
|
})
|
||||||
const marketResult = makeProperty({
|
const portfolioResult = makeProperty({
|
||||||
location: sharedLocation,
|
location: sharedLocation,
|
||||||
address: sharedAddress,
|
address: sharedAddress,
|
||||||
resultType: ResultType.EXTERNAL_MARKET,
|
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||||
})
|
})
|
||||||
|
|
||||||
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
|
||||||
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult)
|
const portfolioEst = softFactorEnrichmentService.estimate('accessibility', portfolioResult)
|
||||||
expect(futureEst?.score).toBe(marketEst?.score)
|
expect(futureEst?.score).toBe(portfolioEst?.score)
|
||||||
expect(futureEst?.label).toBe(marketEst?.label)
|
expect(futureEst?.label).toBe(portfolioEst?.label)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
|
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from '../../components/match-card/MatchCardViewModel'
|
} from '../../components/match-card/MatchCardViewModel'
|
||||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||||
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
|
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
|
||||||
|
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
|
||||||
|
|
||||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||||
|
|
||||||
@@ -137,6 +138,38 @@ export function buildMatchCardViewModel(
|
|||||||
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
|
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
|
||||||
preMarketUnit: unit,
|
preMarketUnit: unit,
|
||||||
preMarketAllUnits: property?.units,
|
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: {
|
scoreBreakdown: {
|
||||||
hardMatchScore: match.scoreBreakdown.hardMatchScore,
|
hardMatchScore: match.scoreBreakdown.hardMatchScore,
|
||||||
softFactorScore: match.scoreBreakdown.softFactorScore,
|
softFactorScore: match.scoreBreakdown.softFactorScore,
|
||||||
|
|||||||
@@ -106,6 +106,22 @@ const RULES: KeywordRule[] = [
|
|||||||
check: (_p) => null, // no structured field yet — always UNKNOWN
|
check: (_p) => null, // no structured field yet — always UNKNOWN
|
||||||
explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst',
|
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 ──────────────────────────────────────────────────
|
// ── Number extraction helpers ──────────────────────────────────────────────────
|
||||||
@@ -115,10 +131,25 @@ function extractNumber(text: string): number | null {
|
|||||||
return m ? parseInt(m[1], 10) : 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 {
|
function isParkingKeyword(text: string): boolean {
|
||||||
return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw))
|
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 ───────────────────────────────────────────────────────────────
|
// ── Main scorer ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function scoreMustHaves(
|
export function scoreMustHaves(
|
||||||
@@ -137,6 +168,41 @@ export function scoreMustHaves(
|
|||||||
const results: MustHaveResult[] = criteria.map((raw) => {
|
const results: MustHaveResult[] = criteria.map((raw) => {
|
||||||
const lower = raw.toLowerCase()
|
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")
|
// Special case: parking with minimum count ("mind. 5 parkplätze")
|
||||||
if (isParkingKeyword(lower)) {
|
if (isParkingKeyword(lower)) {
|
||||||
const required = extractNumber(lower)
|
const required = extractNumber(lower)
|
||||||
|
|||||||
@@ -191,10 +191,10 @@ export function rankMatches(matches: Match[]): Match[] {
|
|||||||
return [...matches].sort((a, b) => {
|
return [...matches].sort((a, b) => {
|
||||||
// Primary: matchScore descending
|
// Primary: matchScore descending
|
||||||
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
|
if (b.matchScore !== a.matchScore) return b.matchScore - a.matchScore
|
||||||
// Secondary: VERIFIED > EXTERNAL / MAISON_WORK > FUTURE
|
// Secondary: VERIFIED / MAISON_WORK > FUTURE
|
||||||
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, EXTERNAL_MARKET: 1, MAISON_WORK: 1, FUTURE_AVAILABILITY: 2 }
|
const typeOrder: Record<string, number> = { VERIFIED_PORTFOLIO: 0, MAISON_WORK: 0, FUTURE_AVAILABILITY: 2 }
|
||||||
const aOrder = typeOrder[a.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
const aOrder = typeOrder[a.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||||
const bOrder = typeOrder[b.resultType ?? 'EXTERNAL_MARKET'] ?? 1
|
const bOrder = typeOrder[b.resultType ?? 'VERIFIED_PORTFOLIO'] ?? 0
|
||||||
if (aOrder !== bOrder) return aOrder - bOrder
|
if (aOrder !== bOrder) return aOrder - bOrder
|
||||||
// Tertiary: higher confidence first
|
// Tertiary: higher confidence first
|
||||||
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
|
return (b.confidenceLevel ?? 0) - (a.confidenceLevel ?? 0)
|
||||||
|
|||||||
@@ -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 {
|
function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
|
||||||
if (!need.requiredFitOut) return null
|
if (!need.requiredFitOut) return null
|
||||||
const propFitOut = property.hardFacts?.fitOut
|
const propFitOut = property.hardFacts?.fitOut
|
||||||
|
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||||
if (!propFitOut) {
|
if (!propFitOut) {
|
||||||
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
|
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 reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
|
||||||
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
|
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
|
||||||
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
|
if (propLevel >= reqLevel) {
|
||||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
|
||||||
return {
|
return {
|
||||||
criterion: 'fitOut',
|
criterion: 'fitOut', weight: 0.04, score: 100, contribution: 4,
|
||||||
weight: 0.04,
|
explanation: `${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung (${LABELS[need.requiredFitOut] ?? need.requiredFitOut})`,
|
||||||
score,
|
}
|
||||||
contribution: score * 0.04,
|
}
|
||||||
explanation: propLevel >= reqLevel
|
// Check if combined budget (tenant + MAB) covers the upgrade gap
|
||||||
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
|
const upgradeKey = `${propFitOut}_${need.requiredFitOut}`
|
||||||
: `${LABELS[propFitOut] ?? propFitOut} — ${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
|
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}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,8 +525,6 @@ export function calcConfidenceModifier(property: Property): number {
|
|||||||
let mod = 0
|
let mod = 0
|
||||||
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
|
||||||
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
|
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
|
||||||
} else if (property.resultType === ResultType.EXTERNAL_MARKET) {
|
|
||||||
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
|
|
||||||
} else if (property.resultType === ResultType.MAISON_WORK) {
|
} else if (property.resultType === ResultType.MAISON_WORK) {
|
||||||
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
mod += CONFIDENCE_MODIFIER.MAISON_WORK
|
||||||
} else {
|
} else {
|
||||||
@@ -586,17 +610,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
]
|
]
|
||||||
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
|
||||||
|
|
||||||
// ── Modifiers: data quality and confidence adjust the final score ─────────
|
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
|
||||||
// These are intentionally applied after the hard/soft weighted sum so that
|
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
|
||||||
// a low-confidence or poor-data property can be penalised without distorting
|
|
||||||
// the individual criterion breakdown.
|
|
||||||
const dataQualityModifier = calcDataQualityModifier(property)
|
|
||||||
const confidenceModifier = calcConfidenceModifier(property)
|
|
||||||
|
|
||||||
// ── Final score: hard/soft weighted sum + must-have penalty + modifiers ───
|
|
||||||
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
|
|
||||||
const rawFinal = baseScore - hardFilter.severePenalty + mustHaveEval.scoreImpact
|
|
||||||
+ dataQualityModifier + confidenceModifier
|
|
||||||
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
|
||||||
|
|
||||||
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
// ── Factor classification — only use weighted soft factors for positive/negative ──
|
||||||
@@ -624,8 +639,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
|
|||||||
finalScore,
|
finalScore,
|
||||||
hardMatchScore,
|
hardMatchScore,
|
||||||
softFactorScore,
|
softFactorScore,
|
||||||
dataQualityModifier,
|
dataQualityModifier: 0,
|
||||||
confidenceModifier,
|
confidenceModifier: 0,
|
||||||
positiveFactors,
|
positiveFactors,
|
||||||
negativeFactors,
|
negativeFactors,
|
||||||
allHardFactors: hardFactors,
|
allHardFactors: hardFactors,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ describe('useCreateNeed', () => {
|
|||||||
preferredLocations: ['Zürich'],
|
preferredLocations: ['Zürich'],
|
||||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
|
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||||
|
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||||
|
confidenceInCriteria: 0.8,
|
||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
||||||
|
|||||||
@@ -71,9 +71,14 @@ describe('useCreateProperty', () => {
|
|||||||
assetType: 'LOGISTICS',
|
assetType: 'LOGISTICS',
|
||||||
resultType: 'VERIFIED_PORTFOLIO',
|
resultType: 'VERIFIED_PORTFOLIO',
|
||||||
location: { city: 'Bern', country: 'CH' },
|
location: { city: 'Bern', country: 'CH' },
|
||||||
|
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '3000', city: 'Bern', country: 'CH' },
|
||||||
areaSqm: 300,
|
areaSqm: 300,
|
||||||
rentPricePerSqm: 100,
|
rentPricePerSqm: 100,
|
||||||
|
availabilityDate: '2025-01-01',
|
||||||
availabilityStatus: 'AVAILABLE_NOW',
|
availabilityStatus: 'AVAILABLE_NOW',
|
||||||
|
sourceType: 'DIRECT',
|
||||||
|
confidenceScore: 0.8,
|
||||||
|
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
|
||||||
organizationId: 'org-test',
|
organizationId: 'org-test',
|
||||||
})
|
})
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { marktHinweisService } from '../services/marktHinweisService'
|
||||||
|
import type { CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||||
|
|
||||||
|
export function useMarktHinweise() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['marktHinweise'],
|
||||||
|
queryFn: () => marktHinweisService.getAll(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMarktHinweis() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateMarktHinweisInput) => marktHinweisService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['marktHinweise'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useMatchDetail } from './useMatches'
|
|||||||
import { propertyService } from '../services/propertyService'
|
import { propertyService } from '../services/propertyService'
|
||||||
import { needService } from '../services/needService'
|
import { needService } from '../services/needService'
|
||||||
import { futureSignalService } from '../services/futureSignalService'
|
import { futureSignalService } from '../services/futureSignalService'
|
||||||
|
import type { ItemResponse } from '../services/types'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
import type { Need } from '../domain/need'
|
import type { Need } from '../domain/need'
|
||||||
import type { FutureSignal } from '../domain/futureSignal'
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
@@ -13,14 +14,14 @@ export function useMatchDetailData(matchId: string) {
|
|||||||
const { data: match, isLoading } = useMatchDetail(matchId)
|
const { data: match, isLoading } = useMatchDetail(matchId)
|
||||||
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
||||||
|
|
||||||
const { data: property = null } = useQuery<Property | null>({
|
const { data: property = null } = useQuery<ItemResponse<Property | null>, Error, Property | null>({
|
||||||
queryKey: ['property', match?.propertyId],
|
queryKey: ['property', match?.propertyId],
|
||||||
queryFn: () => propertyService.getById(match!.propertyId),
|
queryFn: () => propertyService.getById(match!.propertyId),
|
||||||
enabled: !!match && !isFuture,
|
enabled: !!match && !isFuture,
|
||||||
select: r => r.data ?? null,
|
select: r => r.data ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: need = null } = useQuery<Need | null>({
|
const { data: need = null } = useQuery<ItemResponse<Need | null>, Error, Need | null>({
|
||||||
queryKey: ['need', match?.needId],
|
queryKey: ['need', match?.needId],
|
||||||
queryFn: () => needService.getById(match!.needId),
|
queryFn: () => needService.getById(match!.needId),
|
||||||
enabled: !!match?.needId,
|
enabled: !!match?.needId,
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export interface NewListingFormState {
|
|||||||
fitOut: string
|
fitOut: string
|
||||||
parking: string
|
parking: string
|
||||||
ceilingHeight: string
|
ceilingHeight: string
|
||||||
|
mieterausbaubeitrag: string
|
||||||
|
isFlexible: boolean
|
||||||
|
minLettableSqm: string
|
||||||
// Contact
|
// Contact
|
||||||
contactName: string
|
contactName: string
|
||||||
contactEmail: string
|
contactEmail: string
|
||||||
@@ -52,6 +55,7 @@ export interface NewListingFormState {
|
|||||||
// Images
|
// Images
|
||||||
images: string[]
|
images: string[]
|
||||||
imageInput: string
|
imageInput: string
|
||||||
|
floorPlanUrl: string
|
||||||
// AI
|
// AI
|
||||||
aiText: string
|
aiText: string
|
||||||
aiApplied: boolean
|
aiApplied: boolean
|
||||||
@@ -78,10 +82,14 @@ export interface NewListingFormHandlers {
|
|||||||
setFitOut: (v: string) => void
|
setFitOut: (v: string) => void
|
||||||
setParking: (v: string) => void
|
setParking: (v: string) => void
|
||||||
setCeilingHeight: (v: string) => void
|
setCeilingHeight: (v: string) => void
|
||||||
|
setMieterausbaubeitrag: (v: string) => void
|
||||||
|
setIsFlexible: (v: boolean) => void
|
||||||
|
setMinLettableSqm: (v: string) => void
|
||||||
setContactName: (v: string) => void
|
setContactName: (v: string) => void
|
||||||
setContactEmail: (v: string) => void
|
setContactEmail: (v: string) => void
|
||||||
setContactPhone: (v: string) => void
|
setContactPhone: (v: string) => void
|
||||||
setImageInput: (v: string) => void
|
setImageInput: (v: string) => void
|
||||||
|
setFloorPlanUrl: (v: string) => void
|
||||||
setAiText: (v: string) => void
|
setAiText: (v: string) => void
|
||||||
addImage: () => void
|
addImage: () => void
|
||||||
removeImage: (index: number) => void
|
removeImage: (index: number) => void
|
||||||
@@ -101,18 +109,22 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
const [city, setCity] = useState(pre.city ?? '')
|
const [city, setCity] = useState(pre.city ?? '')
|
||||||
const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '')
|
const [areaSqm, setAreaSqm] = useState(pre.areaSqm ? String(pre.areaSqm) : '')
|
||||||
const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '')
|
const [rentPerSqm, setRentPerSqm] = useState(pre.rentPricePerSqm ? String(pre.rentPricePerSqm) : '')
|
||||||
const [availableFrom, setAvailableFrom] = useState('')
|
const [availableFrom, setAvailableFrom] = useState(pre.availableFrom ?? '')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [contactName, setContactName] = useState('')
|
const [contactName, setContactName] = useState('')
|
||||||
const [contactEmail, setContactEmail] = useState('')
|
const [contactEmail, setContactEmail] = useState('')
|
||||||
const [contactPhone, setContactPhone] = useState('')
|
const [contactPhone, setContactPhone] = useState('')
|
||||||
const [softLevels, setSoftLevels] = useState<Record<string, string>>(emptySoftLevels)
|
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
|
||||||
const [floor, setFloor] = useState('')
|
const [floor, setFloor] = useState(pre.floor ?? '')
|
||||||
const [fitOut, setFitOut] = useState('')
|
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
|
||||||
const [parking, setParking] = useState('')
|
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
|
||||||
const [ceilingHeight, setCeilingHeight]= useState('')
|
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
|
||||||
|
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
|
||||||
|
const [isFlexible, setIsFlexible] = useState(pre.isFlexible ?? false)
|
||||||
|
const [minLettableSqm, setMinLettableSqm] = useState(pre.minLettableSqm != null ? String(pre.minLettableSqm) : '')
|
||||||
const [images, setImages] = useState<string[]>([])
|
const [images, setImages] = useState<string[]>([])
|
||||||
const [imageInput, setImageInput] = useState('')
|
const [imageInput, setImageInput] = useState('')
|
||||||
|
const [floorPlanUrl, setFloorPlanUrl] = useState('')
|
||||||
const [aiText, setAiText] = useState('')
|
const [aiText, setAiText] = useState('')
|
||||||
const [aiApplied, setAiApplied] = useState(false)
|
const [aiApplied, setAiApplied] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -157,7 +169,8 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
assetType, street, houseNumber, postalCode, city,
|
assetType, street, houseNumber, postalCode, city,
|
||||||
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
|
||||||
availableFrom, description, softLevels,
|
availableFrom, description, softLevels,
|
||||||
floor, fitOut, parking, ceilingHeight, images,
|
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
|
||||||
|
mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
onSuccess: () => setCreated(true),
|
onSuccess: () => setCreated(true),
|
||||||
@@ -173,7 +186,8 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setContactName(''); setContactEmail(''); setContactPhone('')
|
setContactName(''); setContactEmail(''); setContactPhone('')
|
||||||
setSoftLevels(emptySoftLevels())
|
setSoftLevels(emptySoftLevels())
|
||||||
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
|
||||||
setImages([]); setImageInput('')
|
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
|
||||||
|
setImages([]); setImageInput(''); setFloorPlanUrl('')
|
||||||
setAiText(''); setAiApplied(false)
|
setAiText(''); setAiApplied(false)
|
||||||
setCreated(false); setError(null)
|
setCreated(false); setError(null)
|
||||||
}
|
}
|
||||||
@@ -181,9 +195,9 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
return {
|
return {
|
||||||
assetType, areaSqm, rentPerSqm, availableFrom, description,
|
assetType, areaSqm, rentPerSqm, availableFrom, description,
|
||||||
street, houseNumber, postalCode, city,
|
street, houseNumber, postalCode, city,
|
||||||
softLevels, floor, fitOut, parking, ceilingHeight,
|
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
|
||||||
contactName, contactEmail, contactPhone,
|
contactName, contactEmail, contactPhone,
|
||||||
images, imageInput, aiText, aiApplied,
|
images, imageInput, floorPlanUrl, aiText, aiApplied,
|
||||||
error, created,
|
error, created,
|
||||||
aiParsing: parseListingMutation.isPending,
|
aiParsing: parseListingMutation.isPending,
|
||||||
submitting: createProperty.isPending,
|
submitting: createProperty.isPending,
|
||||||
@@ -191,9 +205,9 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
|
|||||||
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
|
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
|
||||||
setStreet, setHouseNumber, setPostalCode, setCity,
|
setStreet, setHouseNumber, setPostalCode, setCity,
|
||||||
setSoftLevel,
|
setSoftLevel,
|
||||||
setFloor, setFitOut, setParking, setCeilingHeight,
|
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
|
||||||
setContactName, setContactEmail, setContactPhone,
|
setContactName, setContactEmail, setContactPhone,
|
||||||
setImageInput, setAiText,
|
setImageInput, setFloorPlanUrl, setAiText,
|
||||||
addImage, removeImage,
|
addImage, removeImage,
|
||||||
handleAiParse, handleSubmit, resetForm,
|
handleAiParse, handleSubmit, resetForm,
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user