Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a825672197 | |||
| 36570c5bdc | |||
| 98d2770054 | |||
| 6fb2fb027c | |||
| 7f6b7766e6 | |||
| 63909c8caf | |||
| d8ff380d6e | |||
| 81d66d24a9 | |||
| e597e81ff5 | |||
| d2af8664f4 | |||
| cd220ed282 | |||
| a9ee53f185 | |||
| fe8444c1d3 | |||
| 453f2b1982 | |||
| d804d1c923 | |||
| a9e038a64f | |||
| 61a26d00a7 | |||
| f2edc3b2ef |
@@ -3,7 +3,9 @@
|
||||
"allow": [
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(node -e ' *)"
|
||||
"Bash(node -e ' *)",
|
||||
"Bash(git stash *)",
|
||||
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
> Why do I have a folder named ".vercel" in my project?
|
||||
The ".vercel" folder is created when you link a directory to a Vercel project.
|
||||
|
||||
> What does the "project.json" file contain?
|
||||
The "project.json" file contains:
|
||||
- The ID of the Vercel project that you linked ("projectId")
|
||||
- The ID of the user or team your Vercel project is owned by ("orgId")
|
||||
|
||||
> Should I commit the ".vercel" folder?
|
||||
No, you should not share the ".vercel" folder with anyone.
|
||||
Upon creation, it will be automatically added to your ".gitignore" file.
|
||||
@@ -0,0 +1 @@
|
||||
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
|
||||
+1
-3
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
|
||||
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
|
||||
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
|
||||
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
|
||||
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
|
||||
const NewListing = lazy(() => import('./pages/supply/NewListing'))
|
||||
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||
|
||||
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
|
||||
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
|
||||
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
|
||||
|
||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -62,7 +61,6 @@ function App() {
|
||||
<Route path="/supply/future-availability" element={<FutureAvailability />} />
|
||||
<Route path="/supply/data-quality" element={<DataQuality />} />
|
||||
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
|
||||
<Route path="/supply/market-leads" element={<MarketLeads />} />
|
||||
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
|
||||
<Route path="/supply/new-listing" element={<NewListing />} />
|
||||
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||
|
||||
@@ -123,7 +123,6 @@ export function InquiryReplyComposer({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
bgcolor: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
|
||||
borderRadius: 1,
|
||||
|
||||
@@ -1,18 +1,86 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { Box, Divider, Typography } from '@mui/material'
|
||||
import { Building2, FileText, MapPin } from 'lucide-react'
|
||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||
import { mockProperties } from '../../mock-data/properties'
|
||||
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
interface MockPdfPreviewProps {
|
||||
fields: Record<string, string>
|
||||
fallbackFields: Record<string, string>
|
||||
fieldSelections: ReportObjectFieldSelection[]
|
||||
}
|
||||
|
||||
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
|
||||
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
|
||||
areaSqm: 'Fläche',
|
||||
rentPricePerSqm: 'Mietpreis',
|
||||
availabilityDate: 'Verfügbar ab',
|
||||
leaseTerm: 'Mietlaufzeit',
|
||||
breakoutOption: 'Breakout-Option',
|
||||
currentTenant: 'Aktueller Mieter',
|
||||
propertyNumber: 'Objektnummer',
|
||||
assetType: 'Asset Type',
|
||||
floor: 'Stockwerk',
|
||||
parking: 'Parkplätze',
|
||||
fitOut: 'Ausbaugrad',
|
||||
isBarrierFree: 'Barrierefrei',
|
||||
ceilingHeightM: 'Raumhöhe',
|
||||
floorLoad: 'Bodenlast',
|
||||
loadingDocksCount: 'Anlieferung',
|
||||
goodsLift: 'Warenaufzug',
|
||||
passengerLift: 'Personenaufzug',
|
||||
powerSupplyKva: 'Stromanschluss',
|
||||
hasServerRoom: 'Serverraum',
|
||||
internet: 'Internet',
|
||||
deliveryAccess: 'Zufahrt',
|
||||
publicTransportScore: 'ÖV-Anbindung',
|
||||
prestigeScore: 'Prestige',
|
||||
visibilityScore: 'Sichtbarkeit',
|
||||
footfallScore: 'Passantenfrequenz',
|
||||
commuterAccessScore: 'Pendlererreichbarkeit',
|
||||
talentAccessScore: 'Talent Access',
|
||||
esgScore: 'ESG',
|
||||
flexibilityScore: 'Flexibilität',
|
||||
expansionPotentialScore: 'Expansionspotenzial',
|
||||
taxEnvironmentScore: 'Steuerumfeld',
|
||||
microLocation: 'Mikrostandort',
|
||||
competitionEnvironment: 'Konkurrenzumfeld',
|
||||
infrastructure: 'Infrastruktur',
|
||||
description: 'Beschreibung',
|
||||
}
|
||||
|
||||
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
|
||||
switch (key) {
|
||||
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')} m²` : null
|
||||
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
|
||||
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
|
||||
case 'leaseTerm': return property.leaseTerm ?? null
|
||||
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
|
||||
case 'currentTenant': return property.currentTenant ?? null
|
||||
case 'propertyNumber': return property.propertyNumber ?? null
|
||||
case 'assetType': return property.assetType ?? null
|
||||
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
|
||||
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
|
||||
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
|
||||
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
|
||||
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
|
||||
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
|
||||
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
|
||||
case 'fitOut': return property.hardFacts?.fitOut ?? null
|
||||
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
|
||||
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
|
||||
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
|
||||
case 'description': return property.description ?? null
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
|
||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||
|
||||
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
|
||||
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
|
||||
|
||||
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
|
||||
|
||||
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||
}}
|
||||
>
|
||||
{/* ── Page 1 — Offer letter ── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
|
||||
<FileText size={16} />
|
||||
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
|
||||
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{v('closing')}
|
||||
</Typography>
|
||||
|
||||
{/* ── Page 2 — Property details side by side ── */}
|
||||
{hasPropertyPage && (
|
||||
<>
|
||||
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||
Seite 2 — Objektdetails
|
||||
</Typography>
|
||||
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{properties.map(p => {
|
||||
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
|
||||
const optionalFields = selection?.selectedOptionalFields ?? []
|
||||
const image = p.images?.[0]
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={p.id}
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Photo */}
|
||||
{image ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={image}
|
||||
alt={p.title}
|
||||
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Building2 size={22} color="#94a3b8" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<Box sx={{ p: 1.25, flex: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
|
||||
<MapPin size={10} color="#94a3b8" />
|
||||
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
|
||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Selected fields */}
|
||||
{optionalFields.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||
{optionalFields.map(key => {
|
||||
const val = getFieldValue(p, key)
|
||||
if (!val) return null
|
||||
return (
|
||||
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
|
||||
{FIELD_LABELS[key] ?? key}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
|
||||
{val}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
||||
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
|
||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||
@@ -187,7 +187,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
||||
size="small"
|
||||
value={a.date}
|
||||
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ width: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
|
||||
progress={progress}
|
||||
ready={ready}
|
||||
draft={draft}
|
||||
property={property}
|
||||
property={property ?? undefined}
|
||||
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
|
||||
onAttach={() => {
|
||||
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Box, Button, Stack, Typography } from '@mui/material'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useOfferWizardStore } from '../../stores/offerWizardStore'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
|
||||
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||
|
||||
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
|
||||
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
||||
|
||||
export function OfferFieldSelectionStep() {
|
||||
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
|
||||
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
|
||||
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
|
||||
const setStep = useOfferWizardStore(s => s.setStep)
|
||||
|
||||
const { data: allProperties = [] } = useProperties()
|
||||
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
|
||||
|
||||
// Initialise selections when properties are loaded or change
|
||||
useEffect(() => {
|
||||
if (selectedProperties.length === 0) return
|
||||
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
|
||||
const missing = selectedProperties.filter(p => !existing.has(p.id))
|
||||
if (missing.length > 0) {
|
||||
setFieldSelections([
|
||||
...fieldSelections,
|
||||
...missing.map(p => ({
|
||||
propertyId: p.id,
|
||||
mandatoryFields: MANDATORY_FIELDS,
|
||||
selectedOptionalFields: DEFAULT_FIELDS,
|
||||
})),
|
||||
])
|
||||
}
|
||||
// run only when property list or selections change length
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedProperties.length, fieldSelections.length])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
|
||||
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
|
||||
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{selectedProperties.map(p => {
|
||||
const selection: ReportObjectFieldSelection =
|
||||
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
|
||||
propertyId: p.id,
|
||||
mandatoryFields: MANDATORY_FIELDS,
|
||||
selectedOptionalFields: DEFAULT_FIELDS,
|
||||
}
|
||||
return (
|
||||
<ReportObjectFieldSelector
|
||||
key={p.id}
|
||||
propertyTitle={p.title}
|
||||
value={selection}
|
||||
onChange={v => updateFieldSelection(p.id, v)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<Button
|
||||
startIcon={<ArrowLeft size={14} />}
|
||||
onClick={() => setStep('select_properties')}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setStep('pdf_review')}
|
||||
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
|
||||
|
||||
const needTitle = useOfferWizardStore(s => s.needTitle)
|
||||
const addAttachment = useOfferWizardStore(s => s.addAttachment)
|
||||
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
|
||||
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
|
||||
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
|
||||
return
|
||||
}
|
||||
setOfferDraftId(res.data.id)
|
||||
setStep('pdf_review')
|
||||
setStep('select_fields')
|
||||
}
|
||||
|
||||
if (!need) {
|
||||
|
||||
@@ -12,22 +12,25 @@ import {
|
||||
import { X } from 'lucide-react'
|
||||
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
|
||||
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
|
||||
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
|
||||
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
|
||||
import { OfferCheckedAction } from './OfferCheckedAction'
|
||||
import { OfferChatComposer } from './OfferChatComposer'
|
||||
|
||||
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
|
||||
// 'checked' is internal — merged into pdf_review step; 4 visible steps
|
||||
const STEPS: { key: OfferStep; label: string }[] = [
|
||||
{ key: 'select_properties', label: 'Objekte wählen' },
|
||||
{ key: 'select_fields', label: 'Felder wählen' },
|
||||
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
|
||||
{ key: 'send', label: 'Senden' },
|
||||
]
|
||||
|
||||
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
|
||||
select_properties: 0,
|
||||
pdf_review: 1,
|
||||
checked: 1, // same visual step as pdf_review
|
||||
send: 2,
|
||||
select_fields: 1,
|
||||
pdf_review: 2,
|
||||
checked: 2, // same visual step as pdf_review
|
||||
send: 3,
|
||||
}
|
||||
|
||||
export function OfferWizard() {
|
||||
@@ -48,13 +51,15 @@ export function OfferWizard() {
|
||||
fullScreen={fullScreen}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
height: fullScreen ? '100vh' : '85vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -112,6 +117,7 @@ export function OfferWizard() {
|
||||
{/* Step content */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
|
||||
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
|
||||
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
|
||||
{currentStep === 'checked' && <OfferCheckedAction />}
|
||||
{currentStep === 'send' && <OfferChatComposer />}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@mui/material'
|
||||
import { Download, Send, X } from 'lucide-react'
|
||||
import type { Inquiry } from '../../domain/inquiry'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
|
||||
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
@@ -40,10 +39,6 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
||||
|
||||
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
|
||||
|
||||
const selectedProperties = draft?.selectedPropertyIds
|
||||
.map(id => allProperties.find(p => p.id === id))
|
||||
.filter((p): p is Property => !!p) ?? []
|
||||
|
||||
const toggleProperty = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
|
||||
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
|
||||
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
|
||||
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
|
||||
|
||||
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
|
||||
|
||||
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
|
||||
const isSelected = (key: ReportObjectFieldKey) =>
|
||||
value.selectedOptionalFields.includes(key)
|
||||
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
|
||||
|
||||
{FIELD_GROUPS.map(group => {
|
||||
const groupKeys = group.fields.map(f => f.key)
|
||||
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
|
||||
return (
|
||||
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { memo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { TableCell, TableRow } from '@mui/material'
|
||||
import { LABEL_SX, DATA_SX } from './compareUtils'
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompareMetricRowProps {
|
||||
label: string
|
||||
cells: ReactNode[]
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CompareMetricRow = memo(function CompareMetricRow({ label, cells }: CompareMetricRowProps) {
|
||||
return (
|
||||
<TableRow hover>
|
||||
<TableCell sx={LABEL_SX}>{label}</TableCell>
|
||||
{cells.map((cell, i) => (
|
||||
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
Target,
|
||||
CheckSquare,
|
||||
Search,
|
||||
List,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
MessageSquare,
|
||||
Kanban,
|
||||
BellRing,
|
||||
Plus,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -50,12 +49,10 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
|
||||
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
|
||||
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
|
||||
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
|
||||
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
|
||||
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
|
||||
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
|
||||
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
|
||||
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
|
||||
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
|
||||
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
[WorkspaceType.DEMAND]: {
|
||||
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
|
||||
],
|
||||
},
|
||||
[WorkspaceType.OPERATIONS]: {
|
||||
label: 'Operations',
|
||||
abbreviation: 'OP',
|
||||
icon: Settings,
|
||||
firstPath: '/ops/review-queue',
|
||||
chipColor: '#6b21a8',
|
||||
navItems: [
|
||||
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
|
||||
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Ordered list for rendering workspace tabs
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Divider, Typography } from '@mui/material'
|
||||
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
// ── Future Availability Card ──────────────────────────────────────────────────
|
||||
|
||||
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
const isControlled = vm.signalIsControlled ?? false
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -243,4 +244,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -182,7 +182,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||
{vm.tradeoffs[0].description}
|
||||
{vm.tradeoffs[0].concern}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
||||
export { MatchActionToolbar } from './MatchActionToolbar'
|
||||
export { MatchCardSkeleton } from './MatchCardSkeleton'
|
||||
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
|
||||
export type {
|
||||
MatchCardViewModel,
|
||||
MatchCardVariant,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||
import { FloorPlanSection } from './FloorPlanSection'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||
|
||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { PropertyMap } from '../shared'
|
||||
import {
|
||||
ExecutiveSummaryPanel,
|
||||
NeedAlignmentPanel,
|
||||
LocationIntelligencePanel,
|
||||
TradeoffPanel,
|
||||
RiskPanel,
|
||||
MissingInformationPanel,
|
||||
FutureAvailabilityContextPanel,
|
||||
ScoreBreakdownPanel,
|
||||
} from '.'
|
||||
import { MatchDetailPropertySections } from './MatchDetailPropertySections'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Need } from '../../domain/need'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||
|
||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
property: Property | null
|
||||
need: Need | null
|
||||
signal: FutureSignal | null
|
||||
isFuture: boolean
|
||||
taxCalculatorUrl?: string
|
||||
}
|
||||
|
||||
export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown({
|
||||
match,
|
||||
property,
|
||||
need,
|
||||
signal,
|
||||
isFuture,
|
||||
taxCalculatorUrl,
|
||||
}: Props) {
|
||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Full analysis toggle ── */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setShowFullAnalysis(v => !v)}
|
||||
fullWidth
|
||||
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||
sx={{
|
||||
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
|
||||
textTransform: 'none', fontWeight: 500,
|
||||
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
|
||||
}}
|
||||
>
|
||||
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
|
||||
</Button>
|
||||
|
||||
{/* ── Full analysis (hidden by default) ── */}
|
||||
{showFullAnalysis && (
|
||||
<>
|
||||
<ExecutiveSummaryPanel match={match} />
|
||||
<NeedAlignmentPanel match={match} need={need} property={property} />
|
||||
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
|
||||
<LocationIntelligencePanel property={property} />
|
||||
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
||||
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
||||
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
<PropertyMap
|
||||
lat={property.location.coordinates.lat}
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={220}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
<TradeoffPanel match={match} />
|
||||
<RiskPanel match={match} />
|
||||
<MissingInformationPanel match={match} />
|
||||
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
||||
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ExternalLink,
|
||||
Info,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
Tag,
|
||||
Train,
|
||||
TrendingUp,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
|
||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||
export { MatchDetailHero } from './MatchDetailHero'
|
||||
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
|
||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
||||
|
||||
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
|
||||
<TextField
|
||||
label="Fläche (m²)" value={areaSqm}
|
||||
onChange={e => onAreaSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
|
||||
onChange={e => onRentPerSqmChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 1 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
|
||||
@@ -38,12 +38,12 @@ export function TechnicalDetailsSection({
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ min: 0 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)" value={ceilingHeight}
|
||||
onChange={e => onCeilingHeightChange(e.target.value)}
|
||||
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
|
||||
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
disableEscapeKeyDown
|
||||
>
|
||||
<DialogContent sx={{ pt: 4, pb: 2 }}>
|
||||
{SLIDES[activeStep]}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { STAGES, NEXT_STAGE, MOCK_DOCS, RESULT_TYPE_META } from './pipelineConstants'
|
||||
export { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
|
||||
export { DraggableCard } from './PipelineCard'
|
||||
export { DroppableColumn } from './PipelineColumn'
|
||||
export { DetailPanel } from './PipelineDetailPanel'
|
||||
@@ -78,7 +78,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp
|
||||
onClose={ready ? onClose : undefined}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
|
||||
slotProps={{ paper: { sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } } }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { getKpisForRadius } from '../../domain/marketReport'
|
||||
import type { MarketKpis } from '../../domain/marketReport'
|
||||
|
||||
interface Props {
|
||||
baseKpis: MarketKpis
|
||||
assetTypeLabel: string
|
||||
radius: number
|
||||
onRadiusChange: (r: number) => void
|
||||
}
|
||||
|
||||
const RADII = [5, 10, 15, 20]
|
||||
|
||||
function vacancyColor(pct: number): string {
|
||||
if (pct < 4) return '#1a7a4a'
|
||||
if (pct < 8) return '#d97706'
|
||||
return '#dc2626'
|
||||
}
|
||||
|
||||
function vacancyLabel(pct: number): string {
|
||||
if (pct < 4) return 'Engpassmarkt'
|
||||
if (pct < 8) return 'Ausgewogen'
|
||||
return 'Überangebot'
|
||||
}
|
||||
|
||||
function searchesColor(count: number): string {
|
||||
if (count >= 15) return '#1a7a4a'
|
||||
if (count >= 7) return '#d97706'
|
||||
return '#dc2626'
|
||||
}
|
||||
|
||||
function searchesLabel(count: number): string {
|
||||
if (count >= 15) return 'Hohe Nachfrage'
|
||||
if (count >= 7) return 'Moderate Nachfrage'
|
||||
return 'Geringe Nachfrage'
|
||||
}
|
||||
|
||||
function durationColor(months: number): string {
|
||||
if (months < 4) return '#1a7a4a'
|
||||
if (months < 7) return '#d97706'
|
||||
return '#dc2626'
|
||||
}
|
||||
|
||||
function durationLabel(months: number): string {
|
||||
if (months < 4) return 'Schnell absorbiert'
|
||||
if (months < 7) return 'Moderat'
|
||||
return 'Träger Markt'
|
||||
}
|
||||
|
||||
function ratioColor(ratio: number): string {
|
||||
if (ratio >= 2) return '#1a7a4a'
|
||||
if (ratio >= 1) return '#d97706'
|
||||
return '#dc2626'
|
||||
}
|
||||
|
||||
function ratioLabel(ratio: number): string {
|
||||
if (ratio >= 2) return 'Nachfrageüberhang'
|
||||
if (ratio >= 1) return 'Leicht ausgeglichen'
|
||||
return 'Angebotsüberhang'
|
||||
}
|
||||
|
||||
interface KpiCardProps {
|
||||
label: string
|
||||
value: string
|
||||
badge: string
|
||||
badgeColor: string
|
||||
}
|
||||
|
||||
function KpiCard({ label, value, badge, badgeColor }: KpiCardProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
p: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
bgcolor: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 500, fontSize: '0.7rem', lineHeight: 1.2 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, color: '#0f172a', lineHeight: 1 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 0.75,
|
||||
bgcolor: `${badgeColor}18`,
|
||||
alignSelf: 'flex-start',
|
||||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: badgeColor, lineHeight: 1.4 }}>
|
||||
{badge}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarketKpiPanel({ baseKpis, assetTypeLabel, radius, onRadiusChange }: Props) {
|
||||
const kpis = useMemo(
|
||||
() => radius === 5 ? baseKpis : getKpisForRadius(baseKpis, radius),
|
||||
[baseKpis, radius]
|
||||
)
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2.5,
|
||||
p: 1.75,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
bgcolor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.8125rem' }}>
|
||||
Marktumfeld — {assetTypeLabel}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
Vergleichbare Flächen im Umkreis
|
||||
</Typography>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={radius}
|
||||
exclusive
|
||||
onChange={(_, v) => v !== null && onRadiusChange(v)}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiToggleButton-root': {
|
||||
py: 0.25,
|
||||
px: 0.875,
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
border: '1px solid #e2e8f0',
|
||||
color: '#64748b',
|
||||
'&.Mui-selected': {
|
||||
bgcolor: '#1e3a5f',
|
||||
color: '#ffffff',
|
||||
borderColor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#1e3a5f' },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{RADII.map(r => (
|
||||
<ToggleButton key={r} value={r}>
|
||||
{r} km
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<KpiCard
|
||||
label="Leerstandsquote"
|
||||
value={`${kpis.vacancyRatePct.toFixed(1)} %`}
|
||||
badge={vacancyLabel(kpis.vacancyRatePct)}
|
||||
badgeColor={vacancyColor(kpis.vacancyRatePct)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Aktive Suchanfragen"
|
||||
value={String(kpis.activePlatformSearches)}
|
||||
badge={searchesLabel(kpis.activePlatformSearches)}
|
||||
badgeColor={searchesColor(kpis.activePlatformSearches)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Ø Leerstandsdauer"
|
||||
value={`${kpis.avgVacancyMonths.toFixed(1)} Mo.`}
|
||||
badge={durationLabel(kpis.avgVacancyMonths)}
|
||||
badgeColor={durationColor(kpis.avgVacancyMonths)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Nachfrage / Angebot"
|
||||
value={`${kpis.demandSupplyRatio.toFixed(1)}×`}
|
||||
badge={ratioLabel(kpis.demandSupplyRatio)}
|
||||
badgeColor={ratioColor(kpis.demandSupplyRatio)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', mt: 1, display: 'block' }}>
|
||||
Basis: {kpis.comparableListings} vergleichbare {assetTypeLabel}-Flächen im {radius}-km-Radius · KI-gestützte Schätzung
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
|
||||
@@ -97,8 +98,32 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, mb: 0.5 }}>
|
||||
<FileText size={13} color="#64748b" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
Mietvertrag
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<TextField
|
||||
label="Vertrag URL"
|
||||
size="small"
|
||||
value={draft.leaseContractUrl ?? p.leaseContractUrl ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, leaseContractUrl: e.target.value })}
|
||||
placeholder="https://…"
|
||||
slotProps={{ input: { startAdornment: <ExternalLink size={12} style={{ marginRight: 6, color: '#94a3b8', flexShrink: 0 }} /> } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Bezeichnung (optional)"
|
||||
size="small"
|
||||
value={draft.leaseContractName ?? p.leaseContractName ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, leaseContractName: e.target.value })}
|
||||
placeholder="z.B. Mietvertrag 2021–2026"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<FieldGrid>
|
||||
<Field label="Aktueller Mieter" value={p.currentTenant} />
|
||||
<Field label="Mietlaufzeit" value={p.leaseTerm} />
|
||||
@@ -109,6 +134,39 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
<Field label="Importiert aus" value={p.importedFrom} />
|
||||
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
||||
</FieldGrid>
|
||||
|
||||
{/* Mietvertrag */}
|
||||
<Box sx={{ mt: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: p.leaseContractUrl ? '#f8fafc' : '#fafafa' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: p.leaseContractUrl ? 0.75 : 0 }}>
|
||||
<FileText size={13} color={p.leaseContractUrl ? '#1e3a5f' : '#94a3b8'} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: p.leaseContractUrl ? '#1e3a5f' : '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
Mietvertrag
|
||||
</Typography>
|
||||
</Box>
|
||||
{p.leaseContractUrl ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500 }} noWrap>
|
||||
{p.leaseContractName ?? 'Mietvertrag'}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
endIcon={<ExternalLink size={11} />}
|
||||
href={p.leaseContractUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.25, px: 1, borderColor: '#cbd5e1', color: '#1e3a5f', whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
>
|
||||
Öffnen
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
|
||||
Noch kein Mietvertrag hinterlegt — im Bearbeitungsmodus hinzufügen.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floor / unit structure */}
|
||||
|
||||
@@ -28,11 +28,13 @@ import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
|
||||
interface PropertyDetailViewProps {
|
||||
propertyId: string
|
||||
onClose?: () => void
|
||||
hideTabs?: Array<'Matchability' | 'Marktsignale'>
|
||||
}
|
||||
|
||||
const TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
|
||||
const ALL_TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
|
||||
|
||||
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
|
||||
export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: PropertyDetailViewProps) {
|
||||
const TABS = ALL_TABS.filter(t => !hideTabs.includes(t as 'Matchability' | 'Marktsignale'))
|
||||
const [tab, setTab] = useState(0)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<UpdatePropertyInput>({})
|
||||
@@ -175,8 +177,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
</Tabs>
|
||||
|
||||
{/* Tab content */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: tab === 0 ? 2.5 : 0 }}>
|
||||
{tab === 0 && (
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', p: TABS[tab] === 'Übersicht' ? 2.5 : 0 }}>
|
||||
{TABS[tab] === 'Übersicht' && (
|
||||
<PropertyDetailOverview
|
||||
p={property}
|
||||
editing={editing}
|
||||
@@ -184,8 +186,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
||||
onDraftChange={setDraft}
|
||||
/>
|
||||
)}
|
||||
{tab === 1 && <MatchabilityTabPanel propertyId={propertyId} />}
|
||||
{tab === 2 && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
||||
{TABS[tab] === 'Matchability' && <MatchabilityTabPanel propertyId={propertyId} />}
|
||||
{TABS[tab] === 'Marktsignale' && <PropertyMarketSignalsTab propertyId={propertyId} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
|
||||
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
@@ -9,133 +9,176 @@ interface Props {
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
function availabilityBadgeColor(status: string): string {
|
||||
if (status === 'AVAILABLE_NOW') return '#1a7a4a'
|
||||
if (status === 'AVAILABLE_SOON') return '#d97706'
|
||||
return '#64748b'
|
||||
function availabilityColor(status: string) {
|
||||
if (status === 'AVAILABLE_NOW') return { bg: '#dcfce7', text: '#15803d' }
|
||||
if (status === 'AVAILABLE_SOON') return { bg: '#fef9c3', text: '#a16207' }
|
||||
return { bg: '#f1f5f9', text: '#64748b' }
|
||||
}
|
||||
|
||||
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
||||
const confPct = Math.round(p.confidenceScore * 100)
|
||||
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
|
||||
const confColor = p.confidenceScore >= 0.75 ? '#15803d' : p.confidenceScore >= 0.55 ? '#d97706' : '#dc2626'
|
||||
const annualRent = Math.round(p.areaSqm * p.rentPricePerSqm / 1000)
|
||||
const avail = availabilityColor(p.availabilityStatus)
|
||||
const assetColor = getAssetTypeColor(p.assetType)
|
||||
const hasImage = !!p.images?.[0]
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onSelect(p.id)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e8d5c4',
|
||||
background: 'linear-gradient(160deg,#fdf8f3,#faf0e6)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
border: '1px solid #e2e8f0',
|
||||
bgcolor: 'white',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.15s, transform 0.12s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.10)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: '#cbd5e1',
|
||||
},
|
||||
}}
|
||||
onClick={() => onSelect(p.id)}
|
||||
>
|
||||
{/* Image zone */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LocationPreview
|
||||
imageUrl={p.images?.[0]}
|
||||
lat={p.location.coordinates?.lat}
|
||||
lng={p.location.coordinates?.lng}
|
||||
address={`${p.address.street} ${p.address.houseNumber}, ${p.address.city}`}
|
||||
cityLabel={p.location.city}
|
||||
height={175}
|
||||
{/* ── Image / header ── */}
|
||||
<Box sx={{ position: 'relative', height: 120, flexShrink: 0, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||
{hasImage ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={p.images![0]}
|
||||
alt={p.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
|
||||
{/* Availability badge */}
|
||||
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||
size="small"
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: availabilityBadgeColor(p.availabilityStatus),
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
width: '100%', height: '100%',
|
||||
background: `linear-gradient(135deg, ${assetColor}22 0%, ${assetColor}44 100%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Typography sx={{ fontSize: '2.5rem', opacity: 0.3 }}>🏢</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Asset type chip */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
||||
{/* Overlay gradient */}
|
||||
<Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.45) 0%, transparent 55%)' }} />
|
||||
|
||||
{/* Top badges */}
|
||||
<Box sx={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 0.5 }}>
|
||||
<Chip
|
||||
label={getAssetTypeLabel(p.assetType)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: getAssetTypeColor(p.assetType),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
}}
|
||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: assetColor, color: 'white', borderRadius: 1 }}
|
||||
/>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: avail.bg, color: avail.text, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Bottom: annual rent on image */}
|
||||
<Box sx={{ position: 'absolute', bottom: 8, left: 10, right: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.65rem', lineHeight: 1 }}>Jahresmiete ca.</Typography>
|
||||
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1.05rem', lineHeight: 1.2 }}>
|
||||
CHF {annualRent}k
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||
{p.rentPricePerSqm} /m²/J
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card body */}
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{/* ── Card body ── */}
|
||||
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
|
||||
|
||||
{/* Title + location */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, mb: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.3 }}>
|
||||
<MapPin size={11} color="#94a3b8" />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem' }}>
|
||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Key specs */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
||||
<Chip label={`${p.areaSqm} m²`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
<Chip label={`CHF ${p.rentPricePerSqm}/m²/Jahr`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
{p.contractDurationMonths && (
|
||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
{/* Key metrics row */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||
<Maximize2 size={10} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Fläche</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
{p.availabilityDate && (
|
||||
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||
<Calendar size={10} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Ab</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||
{new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{p.softFactors?.prestige != null && (
|
||||
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||
<TrendingUp size={10} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Prestige</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||
{p.softFactors.prestige}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Soft factors */}
|
||||
{/* Soft factor chips */}
|
||||
{p.softFactors && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
||||
{p.softFactors.prestige != null && (
|
||||
<Chip label={`Prestige ${p.softFactors.prestige}`} size="small"
|
||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{p.softFactors.publicTransportMinutes != null && (
|
||||
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
|
||||
)}
|
||||
{p.softFactors.accessibility != null && (
|
||||
<Chip label={`ÖV ${p.softFactors.publicTransportMinutes ?? p.softFactors.accessibility}min`} size="small"
|
||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
||||
{p.hardFacts?.fitOut && (
|
||||
<Chip label={p.hardFacts.fitOut} size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9' }} />
|
||||
)}
|
||||
{p.contractDurationMonths && (
|
||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569' }} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confidence */}
|
||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Datenkonfidenz</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||
{/* Confidence bar */}
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.67rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Datenkonfidenz</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={confPct}
|
||||
sx={{
|
||||
height: 5, borderRadius: 3, bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor },
|
||||
height: 4, borderRadius: 2, bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={e => { e.stopPropagation(); onSelect(p.id) }}
|
||||
sx={{ mt: 1.25, alignSelf: 'flex-end', textTransform: 'none', fontSize: '0.75rem', color: '#1e3a5f', px: 0 }}
|
||||
>
|
||||
Details anzeigen →
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -5,21 +5,33 @@ import { useMarketReport } from '../../hooks/useMarketReport'
|
||||
import type { SignalCategory } from '../../domain/marketReport'
|
||||
import { SignalCard } from './SignalCard'
|
||||
import { BerichtDialog } from './BerichtDialog'
|
||||
import { MarketKpiPanel } from './MarketKpiPanel'
|
||||
|
||||
interface Props {
|
||||
propertyId: string
|
||||
}
|
||||
|
||||
const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
const STATIC_SECTIONS: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
|
||||
{ category: 'demand', label: 'Nachfrage 5 km', icon: <TrendingUp size={16} />, color: '#1a7a4a' },
|
||||
{ category: 'supply', label: 'Angebot 5 km', icon: <BarChart2 size={16} />, color: '#b45309' },
|
||||
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
|
||||
]
|
||||
|
||||
export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||
const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [radius, setRadius] = useState(5)
|
||||
|
||||
const dynamicSections: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ category: 'demand', label: `Nachfrage ${radius} km`, icon: <TrendingUp size={16} />, color: '#1a7a4a' },
|
||||
{ category: 'supply', label: `Angebot ${radius} km`, icon: <BarChart2 size={16} />, color: '#b45309' },
|
||||
]
|
||||
|
||||
const sectionConfig = [
|
||||
STATIC_SECTIONS[0],
|
||||
dynamicSections[0],
|
||||
dynamicSections[1],
|
||||
STATIC_SECTIONS[1],
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -58,7 +70,17 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
|
||||
</Alert>
|
||||
) : (
|
||||
SECTION_CONFIG.map(({ category, label, icon, color }) => {
|
||||
<>
|
||||
{report.kpis && (
|
||||
<MarketKpiPanel
|
||||
baseKpis={report.kpis}
|
||||
assetTypeLabel={report.assetTypeLabel}
|
||||
radius={radius}
|
||||
onRadiusChange={setRadius}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sectionConfig.map(({ category, label, icon, color }) => {
|
||||
const signals = report.signals.filter(s => s.category === category)
|
||||
if (signals.length === 0) return null
|
||||
return (
|
||||
@@ -77,7 +99,8 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
|
||||
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{dialogOpen && (
|
||||
|
||||
@@ -12,18 +12,20 @@ import {
|
||||
FormControlLabel,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
import { X, MapPin, Calendar, DollarSign, Eye, Activity } from 'lucide-react'
|
||||
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||
import { usePropertyById } from '../../hooks/useProperties'
|
||||
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
|
||||
import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, ACTION_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
||||
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
|
||||
|
||||
export function ReminderDetailDrawer() {
|
||||
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
|
||||
const { data: reminder } = useReminder(selectedId ?? '')
|
||||
const { data: reminderProperty } = usePropertyById(reminder?.propertyId ?? '')
|
||||
const complete = useCompleteReminder()
|
||||
const dismiss = useDismissReminder()
|
||||
const snooze = useSnoozeReminder()
|
||||
@@ -101,6 +103,27 @@ export function ReminderDetailDrawer() {
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||
{reminderProperty?.leaseContractUrl ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||
<FileText size={12} color="#1e3a5f" />
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
endIcon={<ExternalLink size={10} />}
|
||||
href={reminderProperty.leaseContractUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#1e3a5f' }}
|
||||
>
|
||||
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<FileText size={12} color="#cbd5e1" />
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { useReminders } from '../../hooks/useReminders'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
@@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard'
|
||||
import { ReminderSkeleton } from './ReminderSkeleton'
|
||||
import { ReminderEmptyState } from './ReminderEmptyState'
|
||||
import type { Reminder } from '../../domain/reminder'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||
|
||||
const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
||||
const MOCK_TODAY = new Date('2026-05-20')
|
||||
|
||||
function getHorizon(dueDate: string): FilterHorizon {
|
||||
const days = Math.ceil((new Date(dueDate).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (days <= 0) return 'OVERDUE'
|
||||
if (days <= 7) return 'THIS_WEEK'
|
||||
if (days <= 30) return 'THIS_MONTH'
|
||||
return 'LATER'
|
||||
}
|
||||
|
||||
const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: string; border: string }[] = [
|
||||
{ key: 'OVERDUE', label: 'Überfällig', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||
{ key: 'THIS_WEEK', label: 'Diese Woche', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
|
||||
{ key: 'THIS_MONTH', label: 'Dieser Monat', color: '#0369a1', bg: '#f0f9ff', border: '#bae6fd' },
|
||||
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e2e8f0' },
|
||||
]
|
||||
|
||||
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
||||
|
||||
function applyFilters(
|
||||
reminders: Reminder[],
|
||||
filterType: string,
|
||||
filterStatus: string,
|
||||
filterPriority: string,
|
||||
filterHorizon: string,
|
||||
searchQuery: string,
|
||||
): Reminder[] {
|
||||
return reminders.filter(r => {
|
||||
if (filterStatus === 'ACTIVE') {
|
||||
if (r.status !== ReminderStatus.ACTIVE && r.status !== ReminderStatus.SNOOZED) return false
|
||||
} else if (filterStatus !== 'ALL') {
|
||||
if (r.status !== filterStatus) return false
|
||||
}
|
||||
if (filterType !== 'ALL' && r.type !== filterType) return false
|
||||
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false
|
||||
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
|
||||
if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||
if (!haystack.includes(q)) return false
|
||||
const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -34,38 +57,60 @@ function applyFilters(
|
||||
export function ReminderFeed() {
|
||||
const { data, isLoading } = useReminders()
|
||||
const {
|
||||
filterType, filterStatus, filterPriority, searchQuery, viewMode,
|
||||
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery,
|
||||
filterType, filterStatus, filterHorizon, searchQuery, viewMode,
|
||||
setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery,
|
||||
} = useReminderStore(useShallow(s => ({
|
||||
filterType: s.filterType, filterStatus: s.filterStatus,
|
||||
filterPriority: s.filterPriority, searchQuery: s.searchQuery,
|
||||
filterHorizon: s.filterHorizon, searchQuery: s.searchQuery,
|
||||
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
||||
setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority,
|
||||
setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon,
|
||||
setSearchQuery: s.setSearchQuery,
|
||||
})))
|
||||
|
||||
const reminders = data ?? []
|
||||
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
|
||||
[reminders, filterType, filterStatus, filterPriority, searchQuery],
|
||||
() => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery),
|
||||
[reminders, filterType, filterStatus, filterHorizon, searchQuery],
|
||||
)
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, Reminder[]> = {
|
||||
OVERDUE: [], THIS_WEEK: [], THIS_MONTH: [], LATER: [],
|
||||
}
|
||||
filtered.forEach(r => map[getHorizon(r.dueDate)].push(r))
|
||||
return map
|
||||
}, [filtered])
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilterType('ALL')
|
||||
setFilterStatus('ALL')
|
||||
setFilterPriority('ALL')
|
||||
setFilterStatus('ACTIVE')
|
||||
setFilterHorizon('ALL')
|
||||
setSearchQuery('')
|
||||
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
|
||||
}, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery])
|
||||
|
||||
if (isLoading) return <ReminderSkeleton />
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return <ReminderEmptyState onReset={resetFilters} />
|
||||
}
|
||||
if (filtered.length === 0) return <ReminderEmptyState onReset={resetFilters} />
|
||||
|
||||
if (viewMode === 'card') {
|
||||
return (
|
||||
<Box className="flex flex-col gap-4">
|
||||
{HORIZON_CONFIG.map(({ key, label, color }) => {
|
||||
const group = grouped[key]
|
||||
if (!group || group.length === 0) return null
|
||||
return (
|
||||
<Box key={key}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, px: 0.5 }}>
|
||||
<Box sx={{ width: 3, height: 16, borderRadius: 1, bgcolor: color, flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color, fontSize: '0.8125rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={group.length}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.65rem', bgcolor: color, color: 'white', ml: 0.25 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
@@ -73,20 +118,22 @@ export function ReminderFeed() {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{filtered.map(r => (
|
||||
<ReminderCard key={r.id} reminder={r} />
|
||||
))}
|
||||
{group.map(r => <ReminderCard key={r.id} reminder={r} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
{/* Table header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: LIST_HEADER_COLS,
|
||||
gridTemplateColumns: LIST_COLS,
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
@@ -101,9 +148,43 @@ export function ReminderFeed() {
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{filtered.map(r => (
|
||||
<ReminderListRow key={r.id} reminder={r} />
|
||||
))}
|
||||
{/* Grouped rows */}
|
||||
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
|
||||
const group = grouped[key]
|
||||
if (!group || group.length === 0) return null
|
||||
return (
|
||||
<Box key={key}>
|
||||
{/* Section divider */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.625,
|
||||
bgcolor: bg,
|
||||
borderTop: `1px solid ${border}`,
|
||||
borderBottom: `1px solid ${border}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: 0.6, fontSize: '0.65rem' }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={group.length}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', bgcolor: color, color: 'white', px: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Rows */}
|
||||
{group.map(r => <ReminderListRow key={r.id} reminder={r} />)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography } from '@mui/material'
|
||||
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography, Select, MenuItem, FormControl, FormControlLabel, Switch } from '@mui/material'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
||||
import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder'
|
||||
import { ReminderType } from '../../domain/reminder'
|
||||
import type { ReminderType as ReminderTypeType } from '../../domain/reminder'
|
||||
|
||||
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||
LEASE_EXPIRY: 'Mietablauf',
|
||||
@@ -16,38 +16,23 @@ const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||
CUSTOM: 'Individuell',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ReminderStatusType, string> = {
|
||||
ACTIVE: 'Aktiv',
|
||||
SNOOZED: 'Schlummern',
|
||||
COMPLETED: 'Erledigt',
|
||||
DISMISSED: 'Verworfen',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<ReminderPriorityType, string> = {
|
||||
URGENT: 'Dringend',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
export function ReminderFilterBar() {
|
||||
const {
|
||||
filterType, setFilterType,
|
||||
filterStatus, setFilterStatus,
|
||||
filterPriority, setFilterPriority,
|
||||
searchQuery, setSearchQuery,
|
||||
viewMode, setViewMode,
|
||||
} = useReminderStore(useShallow(s => ({
|
||||
filterType: s.filterType, setFilterType: s.setFilterType,
|
||||
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
||||
filterPriority: s.filterPriority, setFilterPriority: s.setFilterPriority,
|
||||
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
||||
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
||||
})))
|
||||
|
||||
const showArchive = filterStatus === 'ALL'
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-3">
|
||||
<Box className="flex items-center gap-3 flex-wrap">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{/* Search */}
|
||||
<TextField
|
||||
size="small"
|
||||
@@ -63,7 +48,43 @@ export function ReminderFilterBar() {
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||
sx={{ width: 200, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||
/>
|
||||
|
||||
{/* Type selector */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>Typ:</Typography>
|
||||
<FormControl size="small">
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={e => setFilterType(e.target.value as ReminderTypeType | 'ALL')}
|
||||
sx={{ fontSize: '0.8125rem', minWidth: 150 }}
|
||||
>
|
||||
<MenuItem value="ALL" sx={{ fontSize: '0.8125rem' }}>Alle Typen</MenuItem>
|
||||
{Object.values(ReminderType).map(t => (
|
||||
<MenuItem key={t} value={t} sx={{ fontSize: '0.8125rem' }}>
|
||||
{TYPE_LABELS[t]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Archive toggle */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showArchive}
|
||||
onChange={e => setFilterStatus(e.target.checked ? 'ALL' : 'ACTIVE')}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Abgeschlossene anzeigen
|
||||
</Typography>
|
||||
}
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
|
||||
{/* View mode */}
|
||||
@@ -80,64 +101,5 @@ export function ReminderFilterBar() {
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
{/* Type filter */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Typ:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={filterType}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setFilterType(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
||||
{Object.values(ReminderType).map(t => (
|
||||
<ToggleButton key={t} value={t} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
||||
{TYPE_LABELS[t]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-wrap gap-3">
|
||||
{/* Status filter */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Status:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={filterStatus}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setFilterStatus(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
||||
{Object.values(ReminderStatus).map(s => (
|
||||
<ToggleButton key={s} value={s} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
||||
{STATUS_LABELS[s]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Priority filter */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Priorität:</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={filterPriority}
|
||||
exclusive
|
||||
onChange={(_, v) => v && setFilterPriority(v)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
||||
{Object.values(ReminderPriority).map(p => (
|
||||
<ToggleButton key={p} value={p} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
||||
{PRIORITY_LABELS[p]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import { Box, Paper, Typography, Skeleton } from '@mui/material'
|
||||
import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||
import { Box, Typography, Skeleton } from '@mui/material'
|
||||
import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useReminderInsights } from '../../hooks/useReminders'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||
import { ReminderType } from '../../domain/reminder'
|
||||
|
||||
interface KpiItemProps {
|
||||
interface KpiCardProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: number | string
|
||||
color: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
||||
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
borderColor: '#e2e8f0',
|
||||
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
bgcolor: active ? `${color}10` : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
minWidth: 0,
|
||||
'&:hover': {
|
||||
bgcolor: `${color}0d`,
|
||||
borderColor: color,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 1,
|
||||
bgcolor: `${color}18`,
|
||||
display: 'flex',
|
||||
@@ -41,16 +55,42 @@ function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap', fontSize: '0.7rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReminderKpiBar() {
|
||||
const { data, isLoading } = useReminderInsights()
|
||||
const { filterHorizon, setFilterHorizon, filterType, setFilterType } = useReminderStore(
|
||||
useShallow(s => ({
|
||||
filterHorizon: s.filterHorizon,
|
||||
setFilterHorizon: s.setFilterHorizon,
|
||||
filterType: s.filterType,
|
||||
setFilterType: s.setFilterType,
|
||||
}))
|
||||
)
|
||||
|
||||
function toggleHorizon(h: FilterHorizon) {
|
||||
if (filterHorizon === h) {
|
||||
setFilterHorizon('ALL')
|
||||
} else {
|
||||
setFilterHorizon(h)
|
||||
setFilterType('ALL') // clear type filter so count matches
|
||||
}
|
||||
}
|
||||
|
||||
function togglePreMarket() {
|
||||
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
|
||||
setFilterType('ALL')
|
||||
} else {
|
||||
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
|
||||
setFilterHorizon('ALL') // clear horizon filter so count matches
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -62,33 +102,41 @@ export function ReminderKpiBar() {
|
||||
)
|
||||
}
|
||||
|
||||
const insights = data ?? { urgentCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
||||
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
||||
|
||||
return (
|
||||
<Box className="flex gap-3">
|
||||
<KpiItem
|
||||
icon={<AlertTriangle size={18} color="#dc2626" />}
|
||||
label="Dringend"
|
||||
value={insights.urgentCount}
|
||||
<KpiCard
|
||||
icon={<AlertOctagon size={18} color="#dc2626" />}
|
||||
label="Überfällig"
|
||||
value={ins.overdueCount}
|
||||
color="#dc2626"
|
||||
active={filterHorizon === 'OVERDUE'}
|
||||
onClick={() => toggleHorizon('OVERDUE')}
|
||||
/>
|
||||
<KpiItem
|
||||
<KpiCard
|
||||
icon={<Calendar size={18} color="#ea580c" />}
|
||||
label="Diese Woche"
|
||||
value={insights.dueThisWeek}
|
||||
value={ins.dueThisWeek}
|
||||
color="#ea580c"
|
||||
active={filterHorizon === 'THIS_WEEK'}
|
||||
onClick={() => toggleHorizon('THIS_WEEK')}
|
||||
/>
|
||||
<KpiItem
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={18} color="#0369a1" />}
|
||||
label="Dieser Monat"
|
||||
value={insights.dueThisMonth}
|
||||
value={ins.dueThisMonth}
|
||||
color="#0369a1"
|
||||
active={filterHorizon === 'THIS_MONTH'}
|
||||
onClick={() => toggleHorizon('THIS_MONTH')}
|
||||
/>
|
||||
<KpiItem
|
||||
<KpiCard
|
||||
icon={<Eye size={18} color="#be185d" />}
|
||||
label="Pre-Market Risiko"
|
||||
value={insights.schattenmarktReadyCount}
|
||||
value={ins.schattenmarktReadyCount}
|
||||
color="#be185d"
|
||||
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
|
||||
onClick={togglePreMarket}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,14 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
|
||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { ReminderStatus } from '../../domain/reminder'
|
||||
import { ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
||||
|
||||
const PRIORITY_BORDER: Record<string, string> = {
|
||||
[ReminderPriority.URGENT]: '#dc2626',
|
||||
[ReminderPriority.HIGH]: '#ea580c',
|
||||
[ReminderPriority.MEDIUM]: '#ca8a04',
|
||||
[ReminderPriority.LOW]: 'transparent',
|
||||
}
|
||||
|
||||
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
||||
ACTIVE: 'info',
|
||||
@@ -56,6 +63,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
||||
}
|
||||
|
||||
const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent'
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={handleRowClick}
|
||||
@@ -64,9 +73,11 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
||||
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
pl: 1.5,
|
||||
pr: 2,
|
||||
py: 1.25,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
borderLeft: `3px solid ${borderColor}`,
|
||||
bgcolor: 'white',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
|
||||
|
||||
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
|
||||
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
|
||||
export interface ExtractedContact {
|
||||
type: ContactType
|
||||
value: string
|
||||
confidence: ContactConfidence
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface SignalSource {
|
||||
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
|
||||
url?: string
|
||||
@@ -58,4 +68,7 @@ export interface FutureSignal {
|
||||
|
||||
// Unit-level reference: if set, this signal is about a specific rentable unit
|
||||
unitId?: string
|
||||
|
||||
// KI-extracted contact information from web crawler sources
|
||||
extractedContacts?: ExtractedContact[]
|
||||
}
|
||||
|
||||
@@ -11,8 +11,32 @@ export interface PropertyMarketSignal {
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
// Key market performance indicators for a given radius.
|
||||
// Base values are stored at 5 km; other radii are computed via getKpisForRadius().
|
||||
export interface MarketKpis {
|
||||
vacancyRatePct: number // % of comparable properties currently vacant
|
||||
activePlatformSearches: number // active search requests on the platform
|
||||
avgVacancyMonths: number // average months comparable properties remain vacant
|
||||
demandSupplyRatio: number // demand / supply ratio (> 1 = more demand than supply)
|
||||
comparableListings: number // number of comparable active listings
|
||||
}
|
||||
|
||||
/** Scale base KPIs (at 5 km) for a larger radius. */
|
||||
export function getKpisForRadius(base: MarketKpis, radiusKm: number): MarketKpis {
|
||||
const f = radiusKm / 5
|
||||
return {
|
||||
vacancyRatePct: +Math.min(35, base.vacancyRatePct + (f - 1) * 1.8).toFixed(1),
|
||||
activePlatformSearches: Math.round(base.activePlatformSearches * f * 0.85),
|
||||
avgVacancyMonths: +Math.min(24, base.avgVacancyMonths * (1 + (f - 1) * 0.12)).toFixed(1),
|
||||
demandSupplyRatio: +Math.max(0.3, base.demandSupplyRatio / (1 + (f - 1) * 0.18)).toFixed(1),
|
||||
comparableListings: Math.round(base.comparableListings * f * 1.1),
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarketReport {
|
||||
propertyId: string
|
||||
generatedAt: string
|
||||
assetTypeLabel: string // e.g. "Büro"
|
||||
kpis: MarketKpis // base values at 5 km radius
|
||||
signals: PropertyMarketSignal[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AssetType } from './enums'
|
||||
|
||||
export type HinweisVisibility = 'INTERN' | 'PLATTFORM'
|
||||
export type HinweisDirection = 'SUCHE' | 'VERFUEGBARKEIT'
|
||||
export type HinweisStatus = 'OFFEN' | 'IN_BEARBEITUNG' | 'ARCHIVIERT'
|
||||
export type HinweisQuelle = 'NETZWERKEVENT' | 'TELEFONAT' | 'BESICHTIGUNG' | 'MESSE' | 'EMAIL' | 'SONSTIGES'
|
||||
|
||||
export interface MarktHinweis {
|
||||
id: string
|
||||
createdAt: string
|
||||
createdBy: string
|
||||
verwaltungId: string
|
||||
verwaltungName: string
|
||||
direction: HinweisDirection
|
||||
assetType: AssetType
|
||||
locationHint: string
|
||||
areaSqmMin?: number
|
||||
areaSqmMax?: number
|
||||
companyName?: string
|
||||
isAnonymized: boolean
|
||||
quelle: HinweisQuelle
|
||||
note?: string
|
||||
visibility: HinweisVisibility
|
||||
status: HinweisStatus
|
||||
}
|
||||
|
||||
export type CreateMarktHinweisInput = Omit<MarktHinweis, 'id' | 'createdAt' | 'createdBy' | 'verwaltungId' | 'verwaltungName'>
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
|
||||
} from './enums'
|
||||
import { AvailabilityStatus as AS } from './enums'
|
||||
import type { PropertyUnit } from './unit'
|
||||
|
||||
// Re-export unit types so all existing imports from 'property' continue to work
|
||||
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
|
||||
@@ -135,6 +136,8 @@ export interface Property {
|
||||
description?: string
|
||||
images?: string[]
|
||||
floorPlanUrl?: string
|
||||
leaseContractUrl?: string // link to the signed lease document
|
||||
leaseContractName?: string // display label, e.g. "Mietvertrag 2021–2026"
|
||||
|
||||
propertyNumber?: string
|
||||
units?: PropertyUnit[]
|
||||
@@ -150,7 +153,7 @@ export interface Property {
|
||||
importedAt?: string
|
||||
lastUpdatedAt?: string
|
||||
|
||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number }
|
||||
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
|
||||
|
||||
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
|
||||
lastReviewedAt?: string
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
|
||||
unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
|
||||
}
|
||||
|
||||
/** @deprecated Alias kept for backward compatibility — use VerifiedPortfolioResult */
|
||||
export type ExternalMarketResult = VerifiedPortfolioResult
|
||||
|
||||
export type UnifiedMatchResult =
|
||||
| VerifiedPortfolioResult
|
||||
| FutureAvailabilityResult
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
||||
|
||||
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||
expect(vm.scoreBreakdown!.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||
expect(vm.scoreBreakdown!.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||
expect(vm.scoreBreakdown!.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||
})
|
||||
|
||||
it('does NOT set a disclaimer for verified portfolio results', () => {
|
||||
@@ -65,7 +65,7 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
||||
})
|
||||
|
||||
it('passes through supplied actions unchanged', () => {
|
||||
const actions = [{ label: 'Shortlist', actionType: 'SHORTLIST' as const, primary: true }]
|
||||
const actions = [{ id: 'shortlist', label: 'Shortlist', actionType: 'SAVE_SHORTLIST' as const, primary: true, onClick: () => {} }]
|
||||
const vm = buildMatchCardViewModel(result, actions)
|
||||
expect(vm.actions).toBe(actions)
|
||||
})
|
||||
|
||||
@@ -61,6 +61,9 @@ describe('useCreateNeed', () => {
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
|
||||
organizationId: 'org-test',
|
||||
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||
confidenceInCriteria: 0.8,
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
|
||||
|
||||
@@ -71,9 +71,14 @@ describe('useCreateProperty', () => {
|
||||
assetType: 'LOGISTICS',
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
location: { city: 'Bern', country: 'CH' },
|
||||
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '3000', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 300,
|
||||
rentPricePerSqm: 100,
|
||||
availabilityDate: '2025-01-01',
|
||||
availabilityStatus: 'AVAILABLE_NOW',
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.8,
|
||||
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
|
||||
organizationId: 'org-test',
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { marktHinweisService } from '../services/marktHinweisService'
|
||||
import type { CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||
|
||||
export function useMarktHinweise() {
|
||||
return useQuery({
|
||||
queryKey: ['marktHinweise'],
|
||||
queryFn: () => marktHinweisService.getAll(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateMarktHinweis() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateMarktHinweisInput) => marktHinweisService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['marktHinweise'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useMatchDetail } from './useMatches'
|
||||
import { propertyService } from '../services/propertyService'
|
||||
import { needService } from '../services/needService'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import type { ItemResponse } from '../services/types'
|
||||
import type { Property } from '../domain/property'
|
||||
import type { Need } from '../domain/need'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
@@ -13,14 +14,14 @@ export function useMatchDetailData(matchId: string) {
|
||||
const { data: match, isLoading } = useMatchDetail(matchId)
|
||||
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
||||
|
||||
const { data: property = null } = useQuery<Property | null>({
|
||||
const { data: property = null } = useQuery<ItemResponse<Property | null>, Error, Property | null>({
|
||||
queryKey: ['property', match?.propertyId],
|
||||
queryFn: () => propertyService.getById(match!.propertyId),
|
||||
enabled: !!match && !isFuture,
|
||||
select: r => r.data ?? null,
|
||||
})
|
||||
|
||||
const { data: need = null } = useQuery<Need | null>({
|
||||
const { data: need = null } = useQuery<ItemResponse<Need | null>, Error, Need | null>({
|
||||
queryKey: ['need', match?.needId],
|
||||
queryFn: () => needService.getById(match!.needId),
|
||||
enabled: !!match?.needId,
|
||||
|
||||
@@ -13,6 +13,7 @@ interface PropertyFilter {
|
||||
minAreaSqm?: number
|
||||
maxRentPerSqm?: number
|
||||
organizationId?: string
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export function useProperties(filter?: PropertyFilter) {
|
||||
|
||||
@@ -22,13 +22,13 @@ export function useSchattenmarktSignals(properties: Property[]): FutureSignal[]
|
||||
for (const unit of releasedUnits) {
|
||||
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
|
||||
if (!availableFrom) continue
|
||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths)
|
||||
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths ?? 0)
|
||||
if (MOCK_TODAY < triggerDate) continue
|
||||
signals.push(buildUnitSignal(p, unit, availableFrom))
|
||||
}
|
||||
} else {
|
||||
// Backward compat: property-level signal (no explicit unit releases defined)
|
||||
const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths)
|
||||
const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths ?? 0)
|
||||
if (!triggerDate || MOCK_TODAY < triggerDate) continue
|
||||
signals.push(buildPropertySignal(p))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMatches, useMatchesByNeed } from './useMatches'
|
||||
import { useMatchesByNeed } from './useMatches'
|
||||
import { useProperties } from './useProperties'
|
||||
import { useFutureSignals } from './useFutureSignals'
|
||||
import { useSchattenmarktSignals } from './useSchattenmarktSignals'
|
||||
import type {
|
||||
UnifiedMatchResult,
|
||||
VerifiedPortfolioResult,
|
||||
FutureAvailabilityResult,
|
||||
} from '../domain/unifiedResult'
|
||||
|
||||
export function useUnifiedResults(needId?: string) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Maps a Need id (need-XXX) to its corresponding LatentNeed id (lneed-XXX). */
|
||||
export const NEED_TO_LATENT_NEED: Record<string, string> = {
|
||||
'need-001': 'lneed-007',
|
||||
'need-002': 'lneed-008',
|
||||
'need-004': 'lneed-009',
|
||||
'need-005': 'lneed-010',
|
||||
'need-012': 'lneed-011',
|
||||
'need-013': 'lneed-012',
|
||||
'need-014': 'lneed-013',
|
||||
'need-015': 'lneed-014',
|
||||
}
|
||||
@@ -67,6 +67,10 @@ const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
|
||||
UserRole.PROPERTY_MANAGER,
|
||||
UserRole.DEMAND_USER,
|
||||
],
|
||||
[WorkspaceType.OPERATIONS]: [
|
||||
UserRole.SUPER_ADMIN,
|
||||
UserRole.ORGANIZATION_ADMIN,
|
||||
],
|
||||
}
|
||||
|
||||
// ── Core Functions ────────────────────────────────────────────────────────────
|
||||
|
||||
+167
-119
@@ -422,48 +422,58 @@ export const mockFutureSignals: FutureSignal[] = [
|
||||
unconfirmedFacts: ['Welche Lagerstandorte konkret betroffen', 'Zeitpunkt eines möglichen Auszugs', 'Ob Fläche ganz oder teilweise aufgegeben'],
|
||||
},
|
||||
|
||||
// ── DEMAND signals — company is looking for space → Markt-Leads (Verwaltung) ─
|
||||
// ── DEMAND signals — company is looking for space → KI-Signale (Verwaltung) ─
|
||||
|
||||
// signal-001: DataCloud Systems AG — Büroexpansion Zürich-West
|
||||
// signal-001: Alpbach Advisors AG — Neuer Stadtstandort Zürich
|
||||
// Signal type: Strategic announcement — firm opening Zürich branch for client proximity.
|
||||
// NOT a hiring surge. Trigger is a press release + LinkedIn location updates of key staff.
|
||||
{
|
||||
id: 'signal-001',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'DataCloud Systems AG',
|
||||
locationHint: 'Zürich-West / Technopark',
|
||||
title: 'DataCloud Systems AG sucht ~600 m² Bürofläche in Zürich-West',
|
||||
areaSqmEstimate: 600,
|
||||
probability: 0.72,
|
||||
confidenceScore: 0.68,
|
||||
timeHorizonMonths: 10,
|
||||
companyName: 'Alpbach Advisors AG',
|
||||
locationHint: 'Zürich, Innenstadt / Paradeplatz',
|
||||
title: 'Alpbach Advisors AG eröffnet Zürich-Büro — Standort gesucht',
|
||||
areaSqmEstimate: 320,
|
||||
probability: 0.78,
|
||||
confidenceScore: 0.74,
|
||||
timeHorizonMonths: 8,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/datacloud',
|
||||
publishedAt: '2025-04-20',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'PRESS',
|
||||
url: 'https://example.com/news/alpbach-zuerich-expansion',
|
||||
publishedAt: '2025-04-15',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus öffentlichen Quellen. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'Tech-Sektor Stellenwachstum +38% YoY',
|
||||
relevanceScore: 0.76,
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Medienmitteilung und LinkedIn-Daten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.LOW,
|
||||
marketIndicator: 'Wealth-Management Zürich: Neuansiedlungen +14% 2024',
|
||||
relevanceScore: 0.80,
|
||||
isVerified: false,
|
||||
expiresAt: '2026-03-01',
|
||||
expiresAt: '2026-02-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-05-01T07:00:00Z',
|
||||
updatedAt: '2025-05-10T07:00:00Z',
|
||||
aiSummary: 'KI hat in 90 Tagen 14 Stelleninserate von DataCloud Systems AG mit explizitem Standort Zürich-West/Technopark ausgewertet. Das Wachstumsmuster (DevOps, Cloud-Architektur, Sales) deutet auf eine Teamvergrösserung von ~40 Personen hin — entspricht einem Flächenbedarf von ca. 600 m². Potenzieller Mieter für Büroflächen in Zürich-West.',
|
||||
createdAt: '2025-04-18T08:00:00Z',
|
||||
updatedAt: '2025-05-12T09:00:00Z',
|
||||
aiSummary: 'Alpbach Advisors AG (Luzern, 38 Mitarbeitende, Vermögensverwaltung) hat am 15. April eine Medienmitteilung veröffentlicht: «Alpbach eröffnet Zürich-Büro zur Stärkung der Kundennähe am Finanzplatz». Drei Senior-Partner haben ihren LinkedIn-Standort zeitgleich auf Zürich aktualisiert. Das ist ein strategischer Stadtstandort-Entscheid — kein Wachstum, sondern Nähe zu institutionellen Kunden am Paradeplatz. Typischer Flächenbedarf für ein Repräsentationsbüro dieser Grösse: 280–360 m², Lage Innenstadt oder Seefeld zwingend.',
|
||||
marketIndicators: [
|
||||
'14 Stelleninserate mit Standort Zürich-West in 90 Tagen',
|
||||
'Teamwachstum ~40 Personen (DevOps, Cloud-Architektur, Sales)',
|
||||
'Tech-Sektor Zürich-West: +38% Stellenwachstum 2024',
|
||||
'Medienmitteilung vom 15. April: Zürich-Büro offiziell angekündigt',
|
||||
'3 Senior-Partner haben LinkedIn-Standort auf Zürich aktualisiert',
|
||||
'Wealth-Management Zürich Innenstadt: Nachfrage nach Repräsentationsflächen stabil hoch',
|
||||
],
|
||||
strategicInterpretation: 'Öffentlich bestätigte Standorteröffnung — Flächensuche muss bereits aktiv sein (Ankündigung vor Einzug). Repräsentationsbüro verlangt Innenstadt/Paradeplatz-Nähe. Hohe Abschlusswahrscheinlichkeit da Entscheid bereits gefallen.',
|
||||
confirmedFacts: ['Medienmitteilung öffentlich', 'Zürich-Standort strategisch bestätigt', 'Partner-Team bereits in Zürich aktiv'],
|
||||
unconfirmedFacts: ['Exakte Lage (Paradeplatz vs. Seefeld)', 'Ausbaustandard (Repräsentation vs. Standard)', 'Einzugstermin'],
|
||||
extractedContacts: [
|
||||
{ type: 'WEBSITE', value: 'https://alpbach-advisors.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||
{ type: 'CONTACT_PERSON', value: 'Dr. Markus Suter (Managing Partner)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
|
||||
{ type: 'EMAIL', value: 'zuerich@alpbach-advisors.ch', confidence: 'MEDIUM', label: 'Neue Zürich-Adresse (geschätzt)' },
|
||||
{ type: 'LINKEDIN', value: 'linkedin.com/company/alpbach-advisors', confidence: 'HIGH', label: 'LinkedIn Firmenprofil' },
|
||||
],
|
||||
strategicInterpretation: 'DataCloud Systems AG befindet sich in einer klaren Wachstumsphase mit Fokus auf Zürich-West. Unternehmen mit diesem Wachstumsmuster sucht typischerweise 500–700 m² Bürofläche mit Ausbauoptionen.',
|
||||
confirmedFacts: ['Region Zürich-West aus Inseraten bestätigt', 'Flächentyp Büro plausibel', 'Wachstumstrend seit 6 Monaten vorhanden'],
|
||||
unconfirmedFacts: ['Exakter Flächenbedarf', 'Ob interner Ausbau am aktuellen Standort geplant', 'Zeitplan für die Entscheidung'],
|
||||
},
|
||||
|
||||
// signal-004: Novabio Pharma AG — Laborexpansion Basel Allschwil
|
||||
// signal-004: Novabio Pharma AG — Laborerweiterung Basel Allschwil
|
||||
// Signal type: Business report — explicitly states capacity expansion in Life-Sciences corridor.
|
||||
// Not hiring-driven. Trigger is published strategic plan + lab manager job profiles.
|
||||
{
|
||||
id: 'signal-004',
|
||||
signalType: SignalType.EXPANSION,
|
||||
@@ -491,154 +501,186 @@ export const mockFutureSignals: FutureSignal[] = [
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-02T09:00:00Z',
|
||||
updatedAt: '2025-05-08T10:00:00Z',
|
||||
aiSummary: 'Novabio Pharma AG kommuniziert im Geschäftsbericht 2024 eine "strategische Kapazitätserweiterung im Forschungsbereich Basel-Allschwil bis 2026". 6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil identifiziert. Potenzieller Mieter für Labor-/Büroflächen im Life-Sciences-Korridor.',
|
||||
aiSummary: 'Novabio Pharma AG kommuniziert im Geschäftsbericht 2024 explizit eine «strategische Kapazitätserweiterung im Forschungsbereich Basel-Allschwil bis 2026». Parallel wurden 6 Inserate für Senior Scientists und Lab-Manager mit Standortangabe Allschwil identifiziert. Das Signal ist qualitativ stark: nicht Headcount-getrieben, sondern strategisch kommuniziert. Typischer Laborbedarf für diese Teamgrösse: 400–500 m² Labor + 200–250 m² Büro.',
|
||||
marketIndicators: [
|
||||
'Geschäftsbericht 2024: «Kapazitätserweiterung Basel-Allschwil bis 2026» explizit erwähnt',
|
||||
'6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil in 60 Tagen',
|
||||
'Life-Sciences-Korridor Basel-Allschwil: höchste CH-weite Laborflächen-Nachfrage 2024',
|
||||
],
|
||||
strategicInterpretation: 'Novabio Pharma AG kommuniziert Expansion öffentlich — Flächensuche sehr wahrscheinlich aktiv. Fokus auf Laborpersonal deutet auf kombinierten Lab-/Bürobedarf von 600–800 m² hin.',
|
||||
strategicInterpretation: 'Expansion öffentlich kommuniziert — Flächensuche sehr wahrscheinlich bereits aktiv. Kombination aus publizierter Strategie und Laborpersonal-Inseraten ergibt starkes Signal für Lab-/Bürobedarf von 600–800 m².',
|
||||
confirmedFacts: ['Expansionspläne im Geschäftsbericht bestätigt', 'Region Basel-Allschwil als Zielstandort genannt', 'Einstellungstrend seit Q1 2025 sichtbar'],
|
||||
unconfirmedFacts: ['Ob Neubau oder Bestandsfläche gesucht', 'Exakter Flächenbedarf Labor vs. Büro', 'Zeitpunkt des Einzugs'],
|
||||
extractedContacts: [
|
||||
{ type: 'WEBSITE', value: 'https://novabio.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||
{ type: 'CONTACT_PERSON', value: 'Dr. Anna Hoffmann (CFO)', confidence: 'HIGH', label: 'Aus Geschäftsbericht 2024' },
|
||||
{ type: 'EMAIL', value: 'expansion@novabio.ch', confidence: 'LOW', label: 'Spekulativ — nicht bestätigt' },
|
||||
{ type: 'PHONE', value: '+41 61 200 34 56', confidence: 'MEDIUM', label: 'Hauptnummer laut Website' },
|
||||
],
|
||||
},
|
||||
|
||||
// signal-007: SwissCart E-Commerce GmbH — Logistikexpansion Zug
|
||||
// signal-007: EBH Treuhand AG — Fusion zweier Winterthurer Büros
|
||||
// Signal type: Merger → consolidation → need for larger single space.
|
||||
// NOT hiring. Trigger is Handelsregister entry showing new merged entity.
|
||||
{
|
||||
id: 'signal-007',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'SwissCart E-Commerce GmbH',
|
||||
locationHint: 'Zug, Industriestrasse',
|
||||
title: 'SwissCart E-Commerce GmbH sucht ~580 m² Logistikfläche in Zug',
|
||||
areaSqmEstimate: 580,
|
||||
probability: 0.66,
|
||||
confidenceScore: 0.62,
|
||||
timeHorizonMonths: 9,
|
||||
companyName: 'EBH Treuhand AG',
|
||||
locationHint: 'Winterthur, Zentrum',
|
||||
title: 'EBH Treuhand AG — Fusion zweier Büros, konsolidierter Standort gesucht',
|
||||
areaSqmEstimate: 450,
|
||||
probability: 0.71,
|
||||
confidenceScore: 0.67,
|
||||
timeHorizonMonths: 7,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/swisscart-zug',
|
||||
publishedAt: '2025-04-18',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'MARKET_DATA',
|
||||
url: 'https://example.com/handelsregister/ebh-fusion',
|
||||
publishedAt: '2025-04-02',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'E-Commerce Zug: Stellenwachstum +55% YoY',
|
||||
relevanceScore: 0.69,
|
||||
disclaimer: 'Signal aus Handelsregistereintrag. Fusion bestätigt — Flächenbedarf abgeleitet. Kein bestätigtes Mietverhältnis.',
|
||||
riskLevel: RiskLevel.LOW,
|
||||
marketIndicator: 'Treuhand-Fusionen Zürich/Winterthur 2024–2025: +18% gegenüber Vorjahr',
|
||||
relevanceScore: 0.74,
|
||||
isVerified: false,
|
||||
expiresAt: '2026-02-01',
|
||||
expiresAt: '2026-01-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-20T07:00:00Z',
|
||||
updatedAt: '2025-05-09T08:00:00Z',
|
||||
aiSummary: 'In 45 Tagen wurden 9 Stelleninserate für Lager- und Logistikpersonal von SwissCart E-Commerce GmbH in Zug identifiziert. E-Commerce-Umsatz CH 2024 +22% — Nachfrage nach Lagerflächen steigt. Potenzieller Mieter für Logistikflächen in der Region Zug.',
|
||||
createdAt: '2025-04-05T08:00:00Z',
|
||||
updatedAt: '2025-05-10T09:00:00Z',
|
||||
aiSummary: 'Das Handelsregister Kanton Zürich zeigt am 2. April 2025 die Fusion von «Eichenberger Treuhand GmbH» (200 m², Stadthausstrasse) und «Bretscher & Huber Treuhand AG» (220 m², Tösstalstrasse) zur neuen «EBH Treuhand AG». Beide Einzelfirmen haben separate Mietverträge, die KI auf 2019 bzw. 2021 datiert — typische 6-Jahres-Laufzeiten enden 2025 bzw. 2027. Die fusionierte Entität (ca. 28 Mitarbeitende) benötigt einen gemeinsamen neuen Standort von ~400–500 m². Die Entscheidung dürfte zeitnah fallen.',
|
||||
marketIndicators: [
|
||||
'9 Stelleninserate für Lager- und Logistikpersonal in Zug in 45 Tagen',
|
||||
'E-Commerce-Umsatz CH 2024: +22% — Nachfrage nach Lagerflächen steigt',
|
||||
'Zug Industriestrasse: aktuelle Leerstandsquote Lager unter 3%',
|
||||
'Handelsregistereintrag: Fusion zu EBH Treuhand AG am 2. April 2025 bestätigt',
|
||||
'Kombinierte Teamgrösse ca. 28 Personen — bisherige Flächen je ~200 m² nicht mehr ausreichend',
|
||||
'Treuhand-Fusionen Winterthur 2024: +18% — Konsolidierungsdruck durch Digitalisierung',
|
||||
],
|
||||
strategicInterpretation: 'Fusion ist der stärkste mögliche Indikator für Flächenbedarf — eine neue juristische Einheit muss zwingend einen gemeinsamen Standort finden. Zeitdruck durch auslaufende Altmietverträge. Winterthur Zentrum bevorzugt für Kundenfrequenz.',
|
||||
confirmedFacts: ['Fusion im Handelsregister bestätigt', 'Kombinierte Teamgrösse ca. 28 Personen bekannt', 'Beide Altstandorte bekannt'],
|
||||
unconfirmedFacts: ['Ob einer der Altstandorte weitergeführt wird', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'],
|
||||
extractedContacts: [
|
||||
{ type: 'WEBSITE', value: 'https://ebh-treuhand.ch', confidence: 'HIGH', label: 'Neue Unternehmenswebsite' },
|
||||
{ type: 'CONTACT_PERSON', value: 'Thomas Eichenberger (Geschäftsführer)', confidence: 'HIGH', label: 'Aus Handelsregistereintrag' },
|
||||
{ type: 'EMAIL', value: 'info@ebh-treuhand.ch', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
|
||||
{ type: 'PHONE', value: '+41 52 212 44 00', confidence: 'MEDIUM', label: 'Bisherige Eichenberger-Nummer' },
|
||||
],
|
||||
strategicInterpretation: 'Massiver Personalaufbau im Logistikbereich deutet auf konkreten Flächenbedarf hin. Zug als Steuerkanton bleibt attraktiver Firmensitz — kein Standortwechsel erwartet, nur Erweiterung.',
|
||||
confirmedFacts: ['Region Zug aus Inseraten bestätigt', 'Flächentyp Logistik/Lager aus Stellenprofilen ableitbar', 'Wachstumstrend seit Q3 2024 sichtbar'],
|
||||
unconfirmedFacts: ['Ob zusätzliche Bürofläche benötigt', 'Exakter Flächenbedarf', 'Bevorzugte Strassenlage (Anlieferung)'],
|
||||
},
|
||||
|
||||
// signal-010: Ostschweiz Digital AG — Büroexpansion St. Gallen
|
||||
// signal-010: Centra Schweiz AG — Standortverlagerung Schlieren → Zürich Innenstadt
|
||||
// Signal type: Rightsizing / relocation — company moving from suburban to central location
|
||||
// for talent attraction. NOT about more space — about better location.
|
||||
{
|
||||
id: 'signal-010',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'Ostschweiz Digital AG',
|
||||
locationHint: 'St. Gallen, Riethüsli',
|
||||
title: 'Ostschweiz Digital AG sucht ~480 m² Bürofläche in St. Gallen',
|
||||
areaSqmEstimate: 480,
|
||||
probability: 0.60,
|
||||
confidenceScore: 0.55,
|
||||
timeHorizonMonths: 10,
|
||||
companyName: 'Centra Schweiz AG',
|
||||
locationHint: 'Zürich, Kreis 4 / 5',
|
||||
title: 'Centra Schweiz AG verlagert Standort von Schlieren in Zürich Innenstadt',
|
||||
areaSqmEstimate: 380,
|
||||
probability: 0.66,
|
||||
confidenceScore: 0.62,
|
||||
timeHorizonMonths: 11,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/ostschweiz-digital',
|
||||
publishedAt: '2025-04-22',
|
||||
type: 'PRESS',
|
||||
url: 'https://example.com/news/centra-standortverlagerung',
|
||||
publishedAt: '2025-04-10',
|
||||
credibility: 'MEDIUM',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
disclaimer: 'Signal aus Pressemitteilung und LinkedIn-Jobinseraten. Verlagerungsentscheid kommuniziert — Flächensuche wahrscheinlich aktiv.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024',
|
||||
relevanceScore: 0.62,
|
||||
marketIndicator: 'Zürich Kreis 5: Neuzuzüge Büronutzer +21% 2024',
|
||||
relevanceScore: 0.67,
|
||||
isVerified: false,
|
||||
expiresAt: '2026-03-01',
|
||||
expiresAt: '2026-04-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-25T08:00:00Z',
|
||||
createdAt: '2025-04-12T08:00:00Z',
|
||||
updatedAt: '2025-05-10T07:00:00Z',
|
||||
aiSummary: '8 Stelleninserate für Software-Entwickler und UX-Designer in St. Gallen in 60 Tagen. Mitarbeiterzahl von 22 auf 31 (+41%) in 9 Monaten. Kontinuierliches Wachstum deutet auf Bedarf nach grösserer Bürofläche hin — potenzieller Mieter für Büroflächen in St. Gallen.',
|
||||
aiSummary: 'Centra Schweiz AG (IT-Beratung, aktuell Schlieren, ~35 Mitarbeitende) hat in einer Medienmitteilung «eine Standortoptimierung zur Verbesserung der Mitarbeitergewinnung» angekündigt. Konkret: Verlagerung von Schlieren nach Zürich Innenstadt. Die Fläche wird nicht grösser — sie wird urbaner. KI identifizierte 4 aktuelle Stelleninserate, die explizit «Zürich» als Arbeitsort nennen, obwohl die Firma noch in Schlieren domiziliert ist. Das deutet auf einen laufenden Standortwechsel hin.',
|
||||
marketIndicators: [
|
||||
'8 Stelleninserate für Software-Entwickler und UX-Designer in St. Gallen in 60 Tagen',
|
||||
'Ostschweiz Digital AG: Mitarbeiterzahl von 22 auf 31 (+41%) in 9 Monaten',
|
||||
'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024 (IHK-Bericht)',
|
||||
'Medienmitteilung: Verlagerung Schlieren → Zürich Innenstadt offiziell angekündigt',
|
||||
'4 Stelleninserate nennen «Zürich» als Arbeitsort trotz aktuellem Schlieren-Domizil',
|
||||
'Zürich Kreis 4/5: Neuzuzüge von Tech- und Beratungsunternehmen +21% 2024',
|
||||
],
|
||||
strategicInterpretation: 'Standortverlagerung zur Talentgewinnung — kein Wachstum, sondern Lageoptimierung. Gesuchte Fläche ähnlich gross wie aktuell (~350–400 m²), aber moderner Ausbaustandard und ÖV-Nähe erwartet. Entscheid gefallen, Flächensuche aktiv.',
|
||||
confirmedFacts: ['Verlagerungsentscheid aus Medienmitteilung bestätigt', 'Zielgebiet Zürich Innenstadt kommuniziert', 'Aktuelle Fläche Schlieren ~380 m²'],
|
||||
unconfirmedFacts: ['Exakter Zielbezirk (Kreis 4 vs. 5)', 'Ob Fläche gleichgross oder kleiner', 'Einzugstermin'],
|
||||
extractedContacts: [
|
||||
{ type: 'WEBSITE', value: 'https://centra-schweiz.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||
{ type: 'CONTACT_PERSON', value: 'Nicole Baumann (CEO)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
|
||||
{ type: 'EMAIL', value: 'n.baumann@centra-schweiz.ch', confidence: 'MEDIUM', label: 'Aus Stelleninserat-Signatur' },
|
||||
{ type: 'LINKEDIN', value: 'linkedin.com/company/centra-schweiz', confidence: 'HIGH', label: 'LinkedIn Firmenprofil' },
|
||||
],
|
||||
strategicInterpretation: 'Kontinuierliches Teamwachstum mit Fokus auf St. Gallen deutet auf organische Expansion hin. Erweiterungsfläche am bisherigen Standort oder Umzug in grössere Einheit möglich.',
|
||||
confirmedFacts: ['Region St. Gallen aus Inseraten bestätigt', 'Wachstumstrend seit Q3 2024 nachweisbar', 'Flächentyp Büro aus Stellenprofilen ableitbar'],
|
||||
unconfirmedFacts: ['Ob aktueller Standort Riethüsli oder neue Lage', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'],
|
||||
},
|
||||
|
||||
// signal-013: Geneva Finance Partners SA — Büroexpansion Genf
|
||||
// signal-013: Rheintaler Spedition AG — Spin-off braucht eigenen Standort
|
||||
// Signal type: Corporate spin-off — new legal entity split from parent, needs its own space.
|
||||
// NOT organic growth. Trigger is Handelsregister + parent company press release.
|
||||
{
|
||||
id: 'signal-013',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'Geneva Finance Partners SA',
|
||||
locationHint: 'Genf, La Praille',
|
||||
title: 'Geneva Finance Partners SA sucht ~520 m² Bürofläche in Genf La Praille',
|
||||
areaSqmEstimate: 520,
|
||||
probability: 0.58,
|
||||
confidenceScore: 0.53,
|
||||
timeHorizonMonths: 20,
|
||||
companyName: 'Rheintaler Logistik Solutions AG',
|
||||
locationHint: 'St. Gallen / Rheintal',
|
||||
title: 'Rheintaler Logistik Solutions AG — Spin-off sucht eigenständigen Standort',
|
||||
areaSqmEstimate: 680,
|
||||
probability: 0.69,
|
||||
confidenceScore: 0.65,
|
||||
timeHorizonMonths: 9,
|
||||
source: {
|
||||
type: 'COMPANY_REPORT',
|
||||
url: 'https://example.com/annual/gfp-2025',
|
||||
publishedAt: '2025-03-05',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'MARKET_DATA',
|
||||
url: 'https://example.com/handelsregister/rheintaler-spinoff',
|
||||
publishedAt: '2025-03-20',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Jahresbericht. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'Büroflächennachfrage Genf: +15% 2025',
|
||||
relevanceScore: 0.60,
|
||||
disclaimer: 'Signal aus Handelsregistereintrag und Pressemitteilung der Muttergesellschaft. Neue juristische Einheit — eigenständiger Standort notwendig.',
|
||||
riskLevel: RiskLevel.LOW,
|
||||
marketIndicator: 'Logistik Ostschweiz: Nachfrage nach kombinierten Büro-/Gewerbeflächen stabil',
|
||||
relevanceScore: 0.72,
|
||||
isVerified: false,
|
||||
expiresAt: '2027-01-01',
|
||||
expiresAt: '2026-02-01',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-03-08T11:00:00Z',
|
||||
updatedAt: '2025-05-07T10:00:00Z',
|
||||
aiSummary: 'Jahresbericht 2024: «Expansion des Genfer Teams auf 40 FTE bis 2026» angekündigt. 5 neue Inserate für Senior-Portfoliomanager mit Standort Genf in 30 Tagen. Potenzieller Mieter für Büroflächen in Genf La Praille.',
|
||||
createdAt: '2025-03-25T09:00:00Z',
|
||||
updatedAt: '2025-05-08T10:00:00Z',
|
||||
aiSummary: 'Die Muttergesellschaft «Rheintaler Transport AG» hat am 20. März im Handelsregister die Ausgliederung des Bereichs Logistik-Software in eine eigenständige Tochter «Rheintaler Logistik Solutions AG» eingetragen. Medienmitteilung: «Die neue Einheit agiert ab Q3 2025 vollständig selbständig». Spin-offs dieser Art benötigen zwingend einen eigenen Standort — sie können nicht auf der Mutteradresse firmieren, wenn sie an Kunden pitchen. Typischer Bedarf: 400–500 m² Büro + 150–200 m² kleines Lager/Technikraum.',
|
||||
marketIndicators: [
|
||||
'Jahresbericht 2024: «Expansion des Genfer Teams auf 40 FTE bis 2026» angekündigt',
|
||||
'Genf Finanzsektor: +15% Büroflächennachfrage 2025 (CBRE-Bericht)',
|
||||
'5 neue Inserate für Senior-Portfoliomanager mit Standort Genf in 30 Tagen',
|
||||
'Handelsregistereintrag: neue AG «Rheintaler Logistik Solutions AG» per 20. März',
|
||||
'Medienmitteilung: «Vollständige operative Selbständigkeit ab Q3 2025»',
|
||||
'Logistik-Software Ostschweiz: Nachfrage nach Gewerbeflächen mit Büro wächst',
|
||||
],
|
||||
strategicInterpretation: 'Spin-off ist ein sehr starkes Flächensignal — neue juristische Einheit kann nicht auf der Mutteradresse verbleiben, wenn sie selbständig am Markt auftritt. Zeitdruck durch angekündigte Q3-Selbständigkeit. Kombination Büro + kleines Lager typisch für Logistik-Software-Firmen.',
|
||||
confirmedFacts: ['Handelsregistereintrag neue AG bestätigt', 'Selbständigkeit ab Q3 2025 kommuniziert', 'Muttergesellschaft Standort Rheintal bekannt'],
|
||||
unconfirmedFacts: ['Ob im Rheintal bleiben oder nach St. Gallen', 'Exakter Lagerbedarf', 'Budget pro m²'],
|
||||
extractedContacts: [
|
||||
{ type: 'WEBSITE', value: 'https://rheintaler-logistik-solutions.ch', confidence: 'MEDIUM', label: 'Neue Website (noch im Aufbau)' },
|
||||
{ type: 'CONTACT_PERSON', value: 'Florian Zäch (CEO Spin-off)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
|
||||
{ type: 'EMAIL', value: 'f.zaech@rheintaler-ls.ch', confidence: 'LOW', label: 'Spekulativ — neue Domain' },
|
||||
{ type: 'PHONE', value: '+41 71 750 33 00', confidence: 'MEDIUM', label: 'Bisherige Mutternummer' },
|
||||
],
|
||||
strategicInterpretation: 'Öffentlich kommunizierte Expansion mit klarem Zielhorizont 2026. La Praille bietet kostengünstigere Büroflächen als Genf Innenstadt bei guter ÖV-Anbindung. Flächensuche dürfte aktiv beginnen.',
|
||||
confirmedFacts: ['Expansion im Jahresbericht kommuniziert', 'Teamwachstum auf 40 FTE bis 2026 bestätigt', 'Inserate für Genf aktiv'],
|
||||
unconfirmedFacts: ['Ob La Praille oder andere Lage bevorzugt', 'Exakter Flächenbedarf', 'Ob Repräsentationsbüros benötigt'],
|
||||
},
|
||||
|
||||
// signal-015: BernTech Innovation AG — Büroexpansion Bern Breitenrain
|
||||
// signal-015: BernTech Innovation AG — VC-Finanzierung, Einzug neuer Standort Bern
|
||||
// Signal type: Venture funding → company outgrows current space.
|
||||
// Clear signal: public funding round announcement with explicit growth roadmap.
|
||||
{
|
||||
id: 'signal-015',
|
||||
signalType: SignalType.EXPANSION,
|
||||
signalDirection: 'DEMAND',
|
||||
companyName: 'BernTech Innovation AG',
|
||||
locationHint: 'Bern, Breitenrain',
|
||||
title: 'BernTech Innovation AG sucht ~1\'800 m² Bürofläche in Bern Breitenrain',
|
||||
locationHint: 'Bern, Breitenrain / Lorraine',
|
||||
title: 'BernTech Innovation AG — CHF 12 Mio. Finanzierungsrunde, Standortsuche Bern',
|
||||
areaSqmEstimate: 1800,
|
||||
probability: 0.68,
|
||||
confidenceScore: 0.64,
|
||||
timeHorizonMonths: 16,
|
||||
source: {
|
||||
type: 'JOB_POSTING',
|
||||
url: 'https://example.com/jobs/berntech',
|
||||
publishedAt: '2025-04-25',
|
||||
credibility: 'MEDIUM',
|
||||
type: 'PRESS',
|
||||
url: 'https://example.com/news/berntech-finanzierung-2025',
|
||||
publishedAt: '2025-03-18',
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'INTERNAL',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten und VC-Finanzierungsdaten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
disclaimer: 'Erkannte Flächennachfrage aus VC-Finanzierungsdaten und Pressebericht. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
marketIndicator: 'Bern Tech-Ökosystem: Risikokapital +40% 2024',
|
||||
relevanceScore: 0.66,
|
||||
@@ -647,14 +689,20 @@ export const mockFutureSignals: FutureSignal[] = [
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-04-28T09:00:00Z',
|
||||
updatedAt: '2025-05-10T08:00:00Z',
|
||||
aiSummary: '18 Stelleninserate in 60 Tagen (Fokus Software-Engineering und Product Management). VC-Finanzierungsrunde CHF 12 Mio. im März 2025 öffentlich bestätigt. Frisch finanziertes Wachstumsunternehmen mit Budget für Flächenexpansion — potenzieller Mieter für Büroflächen in Bern.',
|
||||
aiSummary: 'BernTech Innovation AG hat am 18. März eine VC-Finanzierungsrunde von CHF 12 Mio. öffentlich bestätigt (Medien: Startupticker, Handelszeitung). Im Pressebericht explizit: «Die Mittel fliessen in Produktentwicklung, Infrastruktur und ein neues Büro in Bern». Das ist kein abgeleitetes Signal — der CEO hat den Flächenbedarf selbst kommuniziert. Aktuelle Fläche: ~450 m² in Bern Lorraine. Zielgrösse für nächste Phase: 1\'500–2\'000 m².',
|
||||
marketIndicators: [
|
||||
'18 Stelleninserate in 60 Tagen — Fokus Software-Engineering und Product Management',
|
||||
'BernTech Innovation AG: VC-Finanzierungsrunde CHF 12 Mio. im März 2025 bestätigt',
|
||||
'VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt (Startupticker, Handelszeitung)',
|
||||
'CEO-Zitat: «Neues Büro in Bern» explizit als Verwendungszweck kommuniziert',
|
||||
'Bern Tech-Ökosystem: Risikokapital +40% 2024 — stärkstes Wachstum seit 2019',
|
||||
],
|
||||
strategicInterpretation: 'Frisch finanziertes Wachstumsunternehmen mit aktivem Stellenaufbau und gesichertem Budget. VC-Finanzierung als starkes Signal für konkreten Flächenbedarf. Breitenrain als Tech-Standort in Bern etabliert.',
|
||||
confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Region Bern aus Inseraten bestätigt', 'Wachstumstrend seit Q1 2025 sichtbar'],
|
||||
unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung: 1\'500–2\'000 m²)', 'Ob Breitenrain oder andere Berner Lage bevorzugt', 'Zeitpunkt Einzug'],
|
||||
strategicInterpretation: 'CEO hat den Flächenbedarf selbst öffentlich kommuniziert — das ist das stärkste mögliche Signal. Budget vorhanden, Standort Bern bestätigt, Suche aktiv. Breitenrain und Lorraine als etablierte Tech-Quartiere in Bern bevorzugt.',
|
||||
confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Neues Büro Bern vom CEO kommuniziert', 'Aktueller Standort Lorraine ~450 m²'],
|
||||
unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung 1\'500–2\'000 m²)', 'Ob Breitenrain oder andere Berner Lage', 'Einzugstermin'],
|
||||
extractedContacts: [
|
||||
{ type: 'WEBSITE', value: 'https://berntech.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||
{ type: 'EMAIL', value: 'hello@berntech.ch', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
|
||||
{ type: 'CONTACT_PERSON', value: 'Stefan Leuenberger (CEO)', confidence: 'HIGH', label: 'LinkedIn + Pressebericht' },
|
||||
{ type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -146,6 +146,226 @@ export const mockLatentNeeds: LatentNeed[] = [
|
||||
createdAt: '2026-05-09T08:15:00Z',
|
||||
},
|
||||
|
||||
// ─── Suchabos linked to direct listing matches ────────────────────────────
|
||||
|
||||
{
|
||||
id: 'lneed-007',
|
||||
title: 'Modernes Büro in Zürich für 600–1000m²',
|
||||
tenantCompany: 'Innovatech AG',
|
||||
assetType: AssetType.OFFICE,
|
||||
desiredLocation: 'Zürich-West / Kreis 5',
|
||||
sizeRange: { min: 600, max: 1000 },
|
||||
budgetRange: { min: 32, max: 45 },
|
||||
timing: 'Bezug ab Herbst 2025, flexibel',
|
||||
mustHaveCriteria: [
|
||||
'ÖV-Anbindung unter 6 Min',
|
||||
'Mindestfläche 600m²',
|
||||
'Moderner Ausbau',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Lage Zürich-West', weight: 0.35 },
|
||||
{ criterion: 'Ausbaustandard', weight: 0.25, description: 'Modern, repräsentativ' },
|
||||
{ criterion: 'Erweiterungsoption', weight: 0.20 },
|
||||
{ criterion: 'ESG-Standard', weight: 0.10 },
|
||||
{ criterion: 'Parkplätze', weight: 0.10 },
|
||||
],
|
||||
aiSummary: 'Technologieunternehmen mit solidem Wachstum sucht Bürofläche in Zürich-West. Hohe Abschlussbereitschaft bei passendem Objekt.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-04-15T10:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lneed-008',
|
||||
title: 'Logistikfläche Basel-Region 1500–4000m²',
|
||||
tenantCompany: 'Schweizer Logistik GmbH',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
desiredLocation: 'Basel / Muttenz / Pratteln / Reinach BL',
|
||||
sizeRange: { min: 1500, max: 4000 },
|
||||
budgetRange: { min: 12, max: 18 },
|
||||
timing: 'Bezug ab September 2025',
|
||||
mustHaveCriteria: [
|
||||
'Autobahnanschluss unter 5 Min',
|
||||
'Rampe / Andienung vorhanden',
|
||||
'Hallenhöhe min 6m',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Rampenanzahl', weight: 0.30 },
|
||||
{ criterion: 'Lkw-Wendekreis', weight: 0.25 },
|
||||
{ criterion: 'Erweiterungspotenzial', weight: 0.20 },
|
||||
{ criterion: '24/7-Betrieb', weight: 0.15 },
|
||||
{ criterion: 'Sprinkleranlage', weight: 0.10 },
|
||||
],
|
||||
aiSummary: 'Etablierter Logistiker mit klaren Anforderungen. Region Basel priorisiert. Sehr gute Matchbasis mit verfügbaren Hallenflächen.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-03-20T14:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lneed-009',
|
||||
title: 'Premium-Retailfläche Zürich Innenstadt',
|
||||
tenantCompany: 'Retailer Zürich AG',
|
||||
assetType: AssetType.RETAIL,
|
||||
desiredLocation: 'Zürich Innenstadt / Bahnhofstrasse / Niederdorf',
|
||||
sizeRange: { min: 200, max: 500 },
|
||||
budgetRange: { min: 60, max: 100 },
|
||||
timing: 'Bezug ab September 2025, hohe Dringlichkeit',
|
||||
mustHaveCriteria: [
|
||||
'Laufkundschaft und Passantenfrequenz',
|
||||
'Schaufensterfront',
|
||||
'Erdgeschoss',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Fussgängerfrequenz', weight: 0.35 },
|
||||
{ criterion: 'Lagequalität', weight: 0.30, description: 'Zürich Innenstadt' },
|
||||
{ criterion: 'Sichtbarkeit', weight: 0.20 },
|
||||
{ criterion: 'Deckenhöhe', weight: 0.10 },
|
||||
{ criterion: 'Lagerfläche', weight: 0.05 },
|
||||
],
|
||||
aiSummary: 'Retailer mit starkem Markenbewusstsein sucht Toplage in Zürich. Klare Präferenz Innenstadt, solider Abschluss bei Matching.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-03-10T11:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lneed-010',
|
||||
title: 'Büro 300–700m² für Tech-Startup Zug/Zürich',
|
||||
tenantCompany: 'TechStart GmbH',
|
||||
assetType: AssetType.OFFICE,
|
||||
desiredLocation: 'Zug / Zürich / Baar',
|
||||
sizeRange: { min: 300, max: 700 },
|
||||
budgetRange: { min: 30, max: 48 },
|
||||
timing: 'Bezug ab Oktober 2025, flexibel bis Q2 2026',
|
||||
mustHaveCriteria: [
|
||||
'Moderner Ausbau',
|
||||
'Schnelles Internet / Glasfaser',
|
||||
'Fahrradabstellplätze',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Tech-Umfeld / Startup-Nähe', weight: 0.30 },
|
||||
{ criterion: 'ÖV-Anbindung', weight: 0.25 },
|
||||
{ criterion: 'Flexibler Grundriss', weight: 0.20 },
|
||||
{ criterion: 'Steuerumfeld', weight: 0.15, description: 'Zug bevorzugt' },
|
||||
{ criterion: 'ESG-Qualität', weight: 0.10 },
|
||||
],
|
||||
aiSummary: 'Wachsendes Tech-Startup mit flexibler Lage zwischen Zug und Zürich. Hohe Bereitschaft bei modernem Objekt mit gutem Preis-Leistungs-Verhältnis.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-04-28T13:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lneed-011',
|
||||
title: 'Gewerbe-/Lagerfläche Pratteln 400–750m²',
|
||||
tenantCompany: 'Gewerbe Solutions AG',
|
||||
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||
desiredLocation: 'Pratteln / Muttenz / Schweizerhalle',
|
||||
sizeRange: { min: 400, max: 750 },
|
||||
budgetRange: { min: 10, max: 14 },
|
||||
timing: 'Bezug ab April 2026',
|
||||
mustHaveCriteria: [
|
||||
'Lkw-Zufahrt',
|
||||
'Hallenhöhe min 5m',
|
||||
'Drehstrom vorhanden',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Autobahnnähe', weight: 0.30 },
|
||||
{ criterion: 'Aussenanlagefläche', weight: 0.25 },
|
||||
{ criterion: 'Erweiterungspotenzial', weight: 0.20 },
|
||||
{ criterion: 'Parkplätze', weight: 0.15 },
|
||||
{ criterion: 'Büroanteil', weight: 0.10, description: 'Kleines Büro on-site gewünscht' },
|
||||
],
|
||||
aiSummary: 'Gewerbebetrieb mit klaren Anforderungen sucht Fläche im Raum Pratteln. Gute Ausgangslage für verfügbare Gewerbeliegenschaften.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-03-01T09:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lneed-012',
|
||||
title: 'Büro 180–320m² Bern Zentrum / Bahnhof',
|
||||
tenantCompany: 'Bern Consulting AG',
|
||||
assetType: AssetType.OFFICE,
|
||||
desiredLocation: 'Bern Bollwerk / Bahnhof / Innenstadt',
|
||||
sizeRange: { min: 180, max: 320 },
|
||||
budgetRange: { min: 20, max: 30 },
|
||||
timing: 'Bezug ab Mai 2026',
|
||||
mustHaveCriteria: [
|
||||
'ÖV-Anbindung unter 5 Min zum Bahnhof',
|
||||
'Repräsentative Lage',
|
||||
'Konferenzraum',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Bahnhofsnähe', weight: 0.35, description: 'Kundenmeetings erfordern zentrale Lage' },
|
||||
{ criterion: 'Ausbaustandard', weight: 0.25 },
|
||||
{ criterion: 'Flexibilität', weight: 0.20 },
|
||||
{ criterion: 'Natürliches Licht', weight: 0.10 },
|
||||
{ criterion: 'IT-Infrastruktur', weight: 0.10 },
|
||||
],
|
||||
aiSummary: 'Beratungsunternehmen sucht repräsentatives Büro nahe Bern HB. Zentrale Lage ist entscheidend für Kundenkontakte. Guter Match mit Berner Citylagen.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-04-05T10:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lneed-013',
|
||||
title: 'Verkaufsfläche Winterthur Zentrum 150–250m²',
|
||||
tenantCompany: 'Winterthur Handel GmbH',
|
||||
assetType: AssetType.RETAIL,
|
||||
desiredLocation: 'Winterthur Zentrum / Stadthausstrasse / Altstadt',
|
||||
sizeRange: { min: 150, max: 250 },
|
||||
budgetRange: { min: 25, max: 40 },
|
||||
timing: 'Bezug ab Juni 2026',
|
||||
mustHaveCriteria: [
|
||||
'Schaufensterfront',
|
||||
'Erdgeschoss',
|
||||
'Fussgängerfrequenz',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Lage Innenstadt Winterthur', weight: 0.40 },
|
||||
{ criterion: 'Schaufenstergrösse', weight: 0.25 },
|
||||
{ criterion: 'Deckenhöhe', weight: 0.15 },
|
||||
{ criterion: 'Lagerraum', weight: 0.10 },
|
||||
{ criterion: 'Barrierefrei', weight: 0.10 },
|
||||
],
|
||||
aiSummary: 'Lokaler Handelsbetrieb sucht Fläche in Winterthur Innenstadt. Klare Anforderungen, realistisches Budget. Guter Match mit Stadtzentrum-Angeboten.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-03-20T11:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'lneed-014',
|
||||
title: 'Bürofläche 350–600m² Luzern',
|
||||
tenantCompany: 'Luzern Advisory GmbH',
|
||||
assetType: AssetType.OFFICE,
|
||||
desiredLocation: 'Luzern / Luzern-West / Emmen',
|
||||
sizeRange: { min: 350, max: 600 },
|
||||
budgetRange: { min: 18, max: 26 },
|
||||
timing: 'Bezug ab Juni 2026, flexibel bis Januar 2027',
|
||||
mustHaveCriteria: [
|
||||
'ÖV-Anbindung unter 10 Min',
|
||||
'Moderner Ausbau',
|
||||
'Parkplätze vorhanden',
|
||||
],
|
||||
weightedPreferences: [
|
||||
{ criterion: 'Standortqualität Luzern', weight: 0.30 },
|
||||
{ criterion: 'Ausbaustandard', weight: 0.25 },
|
||||
{ criterion: 'Parkplatzverfügbarkeit', weight: 0.20 },
|
||||
{ criterion: 'Erweiterungspotenzial', weight: 0.15 },
|
||||
{ criterion: 'ESG', weight: 0.10 },
|
||||
],
|
||||
aiSummary: 'Beratungsgesellschaft sucht modernes Büro in Luzern. Gute Balance zwischen Lage, Qualität und Budget. Solides Matchprofil.',
|
||||
publicVisibility: true,
|
||||
status: 'public',
|
||||
createdAt: '2026-04-10T14:00:00Z',
|
||||
},
|
||||
|
||||
// ─── Original lneed-006 ────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'lneed-006',
|
||||
title: 'Pop-up-Retailfläche temporär 6 Monate',
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { AssetType } from '../domain/enums'
|
||||
import type { MarktHinweis } from '../domain/marktHinweis'
|
||||
|
||||
export const marktHinweise: MarktHinweis[] = [
|
||||
{
|
||||
id: 'mh-001',
|
||||
createdAt: '2026-05-22T09:15:00Z',
|
||||
createdBy: 'Thomas Müller',
|
||||
verwaltungId: 'v-001',
|
||||
verwaltungName: 'Wincasa AG',
|
||||
direction: 'SUCHE',
|
||||
assetType: AssetType.OFFICE,
|
||||
locationHint: 'Zürich Innenstadt',
|
||||
areaSqmMin: 200,
|
||||
areaSqmMax: 400,
|
||||
companyName: 'Fintech Solutions AG',
|
||||
isAnonymized: false,
|
||||
quelle: 'NETZWERKEVENT',
|
||||
note: 'Sucht modernes Büro, Nähe HB, max CHF 350/m²/J. Kontakt: CEO Andreas Baum',
|
||||
visibility: 'INTERN',
|
||||
status: 'OFFEN',
|
||||
},
|
||||
{
|
||||
id: 'mh-002',
|
||||
createdAt: '2026-05-21T14:30:00Z',
|
||||
createdBy: 'Sarah Keller',
|
||||
verwaltungId: 'v-001',
|
||||
verwaltungName: 'Wincasa AG',
|
||||
direction: 'SUCHE',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
locationHint: 'Pratteln / Muttenz',
|
||||
areaSqmMin: 800,
|
||||
areaSqmMax: 1500,
|
||||
companyName: 'Elektro Bernhard GmbH',
|
||||
isAnonymized: false,
|
||||
quelle: 'TELEFONAT',
|
||||
note: 'Benötigt Lagerfläche mit Rampe und Kran. Einzug Q1/2027.',
|
||||
visibility: 'INTERN',
|
||||
status: 'OFFEN',
|
||||
},
|
||||
{
|
||||
id: 'mh-003',
|
||||
createdAt: '2026-05-20T11:00:00Z',
|
||||
createdBy: 'Thomas Müller',
|
||||
verwaltungId: 'v-001',
|
||||
verwaltungName: 'Wincasa AG',
|
||||
direction: 'VERFUEGBARKEIT',
|
||||
assetType: AssetType.OFFICE,
|
||||
locationHint: 'Basel',
|
||||
areaSqmMin: 350,
|
||||
areaSqmMax: 350,
|
||||
companyName: 'Fläche frei per Sept. 2026',
|
||||
isAnonymized: false,
|
||||
quelle: 'BESICHTIGUNG',
|
||||
note: 'Bürofläche 350m², guter Zustand, 4. OG, Aufzug.',
|
||||
visibility: 'INTERN',
|
||||
status: 'OFFEN',
|
||||
},
|
||||
{
|
||||
id: 'mh-004',
|
||||
createdAt: '2026-05-19T08:45:00Z',
|
||||
createdBy: 'Thomas Müller',
|
||||
verwaltungId: 'v-001',
|
||||
verwaltungName: 'Wincasa AG',
|
||||
direction: 'SUCHE',
|
||||
assetType: AssetType.RETAIL,
|
||||
locationHint: 'Winterthur Altstadt',
|
||||
areaSqmMin: 80,
|
||||
areaSqmMax: 150,
|
||||
companyName: 'Boutique-Kette',
|
||||
isAnonymized: false,
|
||||
quelle: 'MESSE',
|
||||
note: 'Ladenfläche EG, Fussgängerzone bevorzugt.',
|
||||
visibility: 'PLATTFORM',
|
||||
status: 'OFFEN',
|
||||
},
|
||||
{
|
||||
id: 'mh-005',
|
||||
createdAt: '2026-05-18T16:20:00Z',
|
||||
createdBy: 'Petra Züst',
|
||||
verwaltungId: 'v-002',
|
||||
verwaltungName: 'Livit AG',
|
||||
direction: 'SUCHE',
|
||||
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||
locationHint: 'Raum Bern',
|
||||
areaSqmMin: 400,
|
||||
areaSqmMax: 700,
|
||||
isAnonymized: true,
|
||||
quelle: 'TELEFONAT',
|
||||
note: 'Produktionsfirma sucht Gewerbehalle, Deckenhöhe mind. 5m.',
|
||||
visibility: 'PLATTFORM',
|
||||
status: 'OFFEN',
|
||||
},
|
||||
{
|
||||
id: 'mh-006',
|
||||
createdAt: '2026-05-17T10:30:00Z',
|
||||
createdBy: 'Marco Lüthi',
|
||||
verwaltungId: 'v-003',
|
||||
verwaltungName: 'Zurich Invest AG',
|
||||
direction: 'VERFUEGBARKEIT',
|
||||
assetType: AssetType.OFFICE,
|
||||
locationHint: 'Zürich West',
|
||||
areaSqmMin: 550,
|
||||
areaSqmMax: 550,
|
||||
companyName: 'Fläche frei per April 2027',
|
||||
isAnonymized: false,
|
||||
quelle: 'SONSTIGES',
|
||||
note: 'Repräsentatives Büro, Ausbaustandard A, direkte SBB-Anbindung.',
|
||||
visibility: 'PLATTFORM',
|
||||
status: 'OFFEN',
|
||||
},
|
||||
{
|
||||
id: 'mh-007',
|
||||
createdAt: '2026-05-15T09:00:00Z',
|
||||
createdBy: 'Sarah Keller',
|
||||
verwaltungId: 'v-001',
|
||||
verwaltungName: 'Wincasa AG',
|
||||
direction: 'SUCHE',
|
||||
assetType: AssetType.OFFICE,
|
||||
locationHint: 'Luzern',
|
||||
areaSqmMin: 180,
|
||||
areaSqmMax: 320,
|
||||
companyName: 'Startup Hub Luzern',
|
||||
isAnonymized: false,
|
||||
quelle: 'NETZWERKEVENT',
|
||||
note: 'Flexibler Ausbau gewünscht, Co-Working-tauglich.',
|
||||
visibility: 'INTERN',
|
||||
status: 'IN_BEARBEITUNG',
|
||||
},
|
||||
]
|
||||
@@ -1759,4 +1759,321 @@ export const mockMatches: Match[] = [
|
||||
createdAt: '2026-05-20T08:20:00Z',
|
||||
updatedAt: '2026-05-20T08:20:00Z',
|
||||
},
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// DIRECT LISTING MATCHES (prop-d01 … prop-d06)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'match-065',
|
||||
propertyId: 'prop-d01',
|
||||
needId: 'need-005',
|
||||
matchScore: 83,
|
||||
matchStrength: MatchStrength.STRONG,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 88, softFactorScore: 80, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 83 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '320m² liegt im Zielkorridor (300–700m²)' },
|
||||
{ criterion: 'Standort', weight: 0.22, score: 90, contribution: 19.8, explanation: 'Zürich Altstetten — bevorzugte Region Zürich' },
|
||||
{ criterion: 'Budget', weight: 0.17, score: 88, contribution: 14.9, explanation: 'CHF 380/m²/J unter Maximum CHF 576/m²/J' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'ÖV', weight: 0.10, score: 65, contribution: 6.5, explanation: '8 Minuten zur S-Bahn — leicht über Wunsch-Maximum (10 Min.)' },
|
||||
],
|
||||
tradeoffs: [],
|
||||
explainabilitySummary: 'Sehr guter Match für TechStart GmbH: Fläche, Standort Zürich und Budget passen. Minimaler ÖV-Abzug.',
|
||||
confidenceLevel: 0.80,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
uncertaintyIndicators: [],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-15T09:00:00Z',
|
||||
updatedAt: '2026-04-15T09:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-066',
|
||||
propertyId: 'prop-d01',
|
||||
needId: 'need-001',
|
||||
matchScore: 64,
|
||||
matchStrength: MatchStrength.MODERATE,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 64 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Standort', weight: 0.18, score: 88, contribution: 15.8, explanation: 'Zürich Altstetten trifft bevorzugte Region' },
|
||||
{ criterion: 'Budget', weight: 0.14, score: 90, contribution: 12.6, explanation: 'CHF 380/m²/J unter Maximum CHF 540/m²/J' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Fläche', weight: 0.18, score: 40, contribution: 7.2, explanation: '320m² unterschreitet Minimum von 600m² erheblich' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Fläche', concern: '320m² — halb so gross wie Mindestbedarf (600m²)', severity: 'HIGH', mitigation: 'Evtl. Erweiterungsoptionen im Gebäude prüfen' },
|
||||
],
|
||||
explainabilitySummary: 'Standort und Budget passen für Innovatech AG, aber Fläche deutlich zu klein. Nur als Notfall-Option.',
|
||||
confidenceLevel: 0.80,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: ['Fläche unter Minimum'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-15T09:05:00Z',
|
||||
updatedAt: '2026-04-15T09:05:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-067',
|
||||
propertyId: 'prop-d02',
|
||||
needId: 'need-002',
|
||||
matchScore: 66,
|
||||
matchStrength: MatchStrength.MODERATE,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 70, softFactorScore: 78, confidenceModifier: 0.85, dataQualityModifier: 0.82, totalScore: 66 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Pratteln — explizit bevorzugte Region für Schweizer Logistik GmbH' },
|
||||
{ criterion: 'Budget', weight: 0.18, score: 95, contribution: 17.1, explanation: 'CHF 145/m²/J weit unter Maximum CHF 216/m²/J' },
|
||||
{ criterion: 'Hallenhöhe', weight: 0.10, score: 90, contribution: 9.0, explanation: '6.5m Hallenhöhe — Mindestanforderung 6m erfüllt' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Fläche', weight: 0.23, score: 22, contribution: 5.1, explanation: '580m² vs. Minimum 1500m² — erhebliche Unterdeckung' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Fläche', concern: 'Nur 39% des Mindestbedarfs gedeckt', severity: 'HIGH', mitigation: 'Kombinierbar mit benachbarten Einheiten falls verfügbar' },
|
||||
],
|
||||
explainabilitySummary: 'Idealer Standort und Preis für Schweizer Logistik GmbH — Fläche reicht aber nicht aus.',
|
||||
confidenceLevel: 0.85,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: ['Fläche zu gering'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-20T10:00:00Z',
|
||||
updatedAt: '2026-04-20T10:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-068',
|
||||
propertyId: 'prop-d03',
|
||||
needId: 'need-005',
|
||||
matchScore: 61,
|
||||
matchStrength: MatchStrength.MODERATE,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 65, softFactorScore: 70, confidenceModifier: 0.78, dataQualityModifier: 0.74, totalScore: 61 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 295/m²/J deutlich unter Maximum CHF 576/m²/J' },
|
||||
{ criterion: 'Ausbau', weight: 0.08, score: 85, contribution: 6.8, explanation: 'Vollausbau trifft "Moderner Ausbau"-Anforderung' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Standort', weight: 0.22, score: 30, contribution: 6.6, explanation: 'Bern liegt ausserhalb bevorzugter Regionen Zürich/Zug' },
|
||||
{ criterion: 'Fläche', weight: 0.17, score: 62, contribution: 10.5, explanation: '240m² leicht unter Minimum 300m²' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Standort', concern: 'Bern liegt nicht in Zürich/Zug — TechStart GmbH hat keine Niederlassung in Bern', severity: 'MEDIUM', mitigation: 'Falls Remote-Team Bern ausgebaut wird, attraktive Option' },
|
||||
],
|
||||
explainabilitySummary: 'Gutes Budget-Match für TechStart, aber Standort Bern ist nicht ihre bevorzugte Region.',
|
||||
confidenceLevel: 0.78,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: ['Standort ausserhalb Präferenz'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-22T11:00:00Z',
|
||||
updatedAt: '2026-04-22T11:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-069',
|
||||
propertyId: 'prop-d04',
|
||||
needId: 'need-004',
|
||||
matchScore: 62,
|
||||
matchStrength: MatchStrength.MODERATE,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.72, dataQualityModifier: 0.68, totalScore: 62 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Budget', weight: 0.12, score: 100, contribution: 12.0, explanation: 'CHF 360/m²/J weit unter Maximum CHF 1200/m²/J' },
|
||||
{ criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' },
|
||||
{ criterion: 'Erdgeschoss', weight: 0.15, score: 100, contribution: 15.0, explanation: 'EG-Lage wie gefordert' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Standort', weight: 0.20, score: 32, contribution: 6.4, explanation: 'Winterthur vs. Zürich Innenstadt — andere Stadt' },
|
||||
{ criterion: 'Fläche', weight: 0.10, score: 55, contribution: 5.5, explanation: '185m² leicht unter Minimum 200m²' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Standort', concern: 'Winterthur Stadtzentrum ≠ Zürich Innenstadt', severity: 'MEDIUM', mitigation: 'Winterthur hat hohe Fussgängerfrequenz, günstigere Miete' },
|
||||
],
|
||||
explainabilitySummary: 'Gute Retail-Attribute, aber Winterthur ist nicht das gesuchte Zürich-Innenstadt-Format.',
|
||||
confidenceLevel: 0.72,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: ['Standort differiert'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-02-15T14:00:00Z',
|
||||
updatedAt: '2026-02-15T14:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-070',
|
||||
propertyId: 'prop-d05',
|
||||
needId: 'need-002',
|
||||
matchScore: 81,
|
||||
matchStrength: MatchStrength.STRONG,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 85, softFactorScore: 82, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 81 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Muttenz — explizit bevorzugte Region für Schweizer Logistik GmbH' },
|
||||
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 130/m²/J deutlich unter Maximum CHF 216/m²/J' },
|
||||
{ criterion: 'Hallenhöhe', weight: 0.10, score: 100, contribution: 10.0, explanation: '9m — deutlich über Mindestanforderung 6m' },
|
||||
{ criterion: 'Ladelucken', weight: 0.08, score: 100, contribution: 8.0, explanation: '4 Ladelucken vorhanden — Anforderung erfüllt' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Fläche', weight: 0.23, score: 65, contribution: 14.9, explanation: '1200m² leicht unter Minimum 1500m² (-20%)' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Fläche', concern: '1200m² vs. Minimum 1500m² — 20% Unterdeckung', severity: 'LOW', mitigation: 'Erweiterungspotenzial auf Nachbargrundstück vorhanden' },
|
||||
],
|
||||
explainabilitySummary: 'Starker Logistik-Match: Muttenz, Hallenhöhe, Budget und Ladelucken passen. Fläche leicht unter Minimum.',
|
||||
confidenceLevel: 0.88,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
uncertaintyIndicators: [],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-05-01T08:00:00Z',
|
||||
updatedAt: '2026-05-01T08:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-071',
|
||||
propertyId: 'prop-d06',
|
||||
needId: 'need-005',
|
||||
matchScore: 70,
|
||||
matchStrength: MatchStrength.MODERATE,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 75, softFactorScore: 72, confidenceModifier: 0.70, dataQualityModifier: 0.65, totalScore: 70 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '450m² liegt im Zielkorridor (300–700m²)' },
|
||||
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 260/m²/J deutlich unter Maximum CHF 576/m²/J' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Standort', weight: 0.22, score: 42, contribution: 9.2, explanation: 'Luzern liegt ausserhalb bevorzugter Regionen Zürich/Zug/Baar' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Standort', concern: 'Luzern ist 45 Min. von Zürich entfernt', severity: 'MEDIUM', mitigation: 'Attraktiver Preis und S-Bahn-Direktverbindung könnten Kompromiss sein' },
|
||||
],
|
||||
explainabilitySummary: 'Fläche und Budget passen sehr gut für TechStart GmbH. Luzern ist nicht ihre Wunschregion, aber preislich attraktiv.',
|
||||
confidenceLevel: 0.70,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
uncertaintyIndicators: ['Standort ausserhalb Präferenz', 'Inserat inaktiv'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-01-15T09:00:00Z',
|
||||
updatedAt: '2026-01-15T09:00:00Z',
|
||||
},
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Direct listings — Strong matches (>80%) ensuring all 6 have ≥1 STRONG
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'match-072',
|
||||
propertyId: 'prop-d02',
|
||||
needId: 'need-012',
|
||||
matchScore: 85,
|
||||
matchStrength: MatchStrength.STRONG,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 88, softFactorScore: 84, confidenceModifier: 0.90, dataQualityModifier: 0.87, totalScore: 85 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Standort', weight: 0.20, score: 98, contribution: 19.6, explanation: 'Pratteln — explizit bevorzugte Region für Gewerbe Solutions AG' },
|
||||
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 145/m²/J deutlich unter Maximum CHF 168/m²/J' },
|
||||
{ criterion: 'Fläche', weight: 0.22, score: 90, contribution: 19.8, explanation: '580m² liegt ideal im Zielkorridor (400–750m²)' },
|
||||
{ criterion: 'Lkw-Zufahrt', weight: 0.10, score: 100, contribution: 10.0, explanation: 'Lkw-Zufahrt vorhanden — Pflichtkriterium erfüllt' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Hallenhöhe', weight: 0.08, score: 70, contribution: 5.6, explanation: 'Angabe Hallenhöhe nicht vollständig dokumentiert' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Hallenhöhe', concern: 'Angabe Hallenhöhe unvollständig — Besichtigung nötig', severity: 'LOW', mitigation: 'Vor Mietabschluss Vor-Ort-Prüfung empfohlen' },
|
||||
],
|
||||
explainabilitySummary: 'Ausgezeichneter Standort und Preis-Leistungs-Verhältnis für Gewerbe Solutions AG. Pratteln ist die Wunschregion, Fläche und Budget passen sehr gut.',
|
||||
confidenceLevel: 0.90,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
uncertaintyIndicators: [],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-05-10T08:00:00Z',
|
||||
updatedAt: '2026-05-10T08:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-073',
|
||||
propertyId: 'prop-d03',
|
||||
needId: 'need-013',
|
||||
matchScore: 84,
|
||||
matchStrength: MatchStrength.STRONG,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 87, softFactorScore: 83, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 84 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Standort', weight: 0.25, score: 96, contribution: 24.0, explanation: 'Bern Bollwerk — perfekter Fit mit Bern Innenstadt / Bahnhof-Nähe' },
|
||||
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 295/m²/J unter Maximum CHF 360/m²/J' },
|
||||
{ criterion: 'Fläche', weight: 0.18, score: 92, contribution: 16.6, explanation: '240m² liegt gut im Korridor (180–320m²)' },
|
||||
{ criterion: 'ÖV', weight: 0.09, score: 100, contribution: 9.0, explanation: 'Hauptbahnhof Bern < 5 Min zu Fuss' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Konferenzraum', weight: 0.06, score: 60, contribution: 3.6, explanation: 'Konferenzraumsituation nicht explizit dokumentiert' },
|
||||
],
|
||||
tradeoffs: [],
|
||||
explainabilitySummary: 'Starke Übereinstimmung: Bern Bollwerk trifft genau die Anforderungen von Bern Consulting AG — Lage, Preis und Fläche passen.',
|
||||
confidenceLevel: 0.88,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
uncertaintyIndicators: [],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-05-10T09:00:00Z',
|
||||
updatedAt: '2026-05-10T09:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-074',
|
||||
propertyId: 'prop-d04',
|
||||
needId: 'need-014',
|
||||
matchScore: 82,
|
||||
matchStrength: MatchStrength.STRONG,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 86, softFactorScore: 80, confidenceModifier: 0.84, dataQualityModifier: 0.82, totalScore: 82 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Standort', weight: 0.22, score: 97, contribution: 21.3, explanation: 'Winterthur Stadthausstrasse — Wunschlage für Winterthur Handel GmbH' },
|
||||
{ criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' },
|
||||
{ criterion: 'Erdgeschoss', weight: 0.12, score: 100, contribution: 12.0, explanation: 'EG-Lage wie gefordert' },
|
||||
{ criterion: 'Budget', weight: 0.14, score: 100, contribution: 14.0, explanation: 'CHF 360/m²/J deutlich unter Maximum CHF 480/m²/J' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Fläche', weight: 0.12, score: 68, contribution: 8.2, explanation: '185m² leicht unter Minimum 150m², aber am unteren Rand' },
|
||||
{ criterion: 'Inaktiv', weight: 0.04, score: 0, contribution: 0, explanation: 'Inserat derzeit inaktiv — Verfügbarkeit zu klären' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Status', concern: 'Inserat inaktiv — Verfügbarkeit muss bestätigt werden', severity: 'LOW', mitigation: 'Direkter Kontakt mit Eigentümer empfohlen' },
|
||||
],
|
||||
explainabilitySummary: 'Winterthur Handel GmbH findet hier genau die gesuchte Retailfläche am richtigen Ort. Status zu klären.',
|
||||
confidenceLevel: 0.84,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
uncertaintyIndicators: ['Inserat inaktiv'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-05-10T10:00:00Z',
|
||||
updatedAt: '2026-05-10T10:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'match-075',
|
||||
propertyId: 'prop-d06',
|
||||
needId: 'need-015',
|
||||
matchScore: 83,
|
||||
matchStrength: MatchStrength.STRONG,
|
||||
status: MatchStatus.PENDING_REVIEW,
|
||||
scoreBreakdown: { hardMatchScore: 86, softFactorScore: 82, confidenceModifier: 0.86, dataQualityModifier: 0.84, totalScore: 83 },
|
||||
positiveFactors: [
|
||||
{ criterion: 'Standort', weight: 0.22, score: 95, contribution: 20.9, explanation: 'Luzern Zürichstrasse — Wunschlage für Luzern Advisory GmbH' },
|
||||
{ criterion: 'Fläche', weight: 0.19, score: 96, contribution: 18.2, explanation: '450m² liegt ideal im Korridor (350–600m²)' },
|
||||
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 260/m²/J klar unter Maximum CHF 312/m²/J' },
|
||||
{ criterion: 'ÖV', weight: 0.08, score: 88, contribution: 7.0, explanation: 'Gute S-Bahn-Anbindung Luzern Zentrum' },
|
||||
],
|
||||
negativeFactors: [
|
||||
{ criterion: 'Inaktiv', weight: 0.04, score: 0, contribution: 0, explanation: 'Inserat derzeit inaktiv — Bezugsdatum offen' },
|
||||
],
|
||||
tradeoffs: [
|
||||
{ criterion: 'Status', concern: 'Inserat inaktiv — Aktivierung und Verfügbarkeit zu klären', severity: 'LOW', mitigation: 'Direkte Anfrage an Eigentümer empfohlen' },
|
||||
],
|
||||
explainabilitySummary: 'Sehr guter Match für Luzern Advisory GmbH: Lage, Fläche und Budget passen ausgezeichnet. Inserat-Status zu klären.',
|
||||
confidenceLevel: 0.86,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
uncertaintyIndicators: ['Inserat inaktiv'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-05-10T11:00:00Z',
|
||||
updatedAt: '2026-05-10T11:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -437,6 +437,142 @@ export const mockNeeds: Need[] = [
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
|
||||
{
|
||||
id: 'need-012',
|
||||
companyName: 'Gewerbe Solutions AG',
|
||||
contactName: 'Andreas Weber',
|
||||
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||
requiredArea: { min: 400, max: 750 },
|
||||
preferredLocations: ['Pratteln', 'Muttenz', 'Schweizerhalle', 'Liestal'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 168, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-04-01',
|
||||
latestMoveIn: '2026-10-01',
|
||||
contractDurationMonths: 48,
|
||||
flexibleTiming: true,
|
||||
},
|
||||
mustCriteriaText: ['Lkw-Zufahrt', 'Hallenhöhe min 5m', 'Drehstrom vorhanden'],
|
||||
softFactors: {
|
||||
requireParking: true,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.22, location: 0.20, budget: 0.18, timing: 0.12,
|
||||
prestige: 0.02, accessibility: 0.10, expansionPotential: 0.06, flexibility: 0.03,
|
||||
visibility: 0.01, footfall: 0.00, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.01,
|
||||
},
|
||||
confidenceInCriteria: 0.90,
|
||||
extractedFromText: 'Gewerbe-/Lagerfläche 450–700m² in Pratteln oder Muttenz, Lkw-Zufahrt, max. CHF 168/m²/J.',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-03-01T09:00:00Z',
|
||||
updatedAt: '2026-03-01T09:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-013: Bern Consulting AG — OFFICE Bern ---
|
||||
{
|
||||
id: 'need-013',
|
||||
companyName: 'Bern Consulting AG',
|
||||
contactName: 'Sabine Gerber',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 180, max: 320 },
|
||||
preferredLocations: ['Bern', 'Bern Bollwerk', 'Bern Bahnhof', 'Bern Innenstadt'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 360, maxMonthlyTotal: 8500, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-05-01',
|
||||
latestMoveIn: '2026-11-01',
|
||||
contractDurationMonths: 48,
|
||||
flexibleTiming: true,
|
||||
},
|
||||
mustCriteriaText: ['ÖV-Anbindung < 5 Min', 'Repräsentative Lage', 'Konferenzraum vorhanden'],
|
||||
softFactors: {
|
||||
minPrestige: 65,
|
||||
minAccessibility: 80,
|
||||
requireParking: false,
|
||||
maxPublicTransportMinutes: 5,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.18, location: 0.25, budget: 0.18, timing: 0.11,
|
||||
prestige: 0.08, accessibility: 0.09, expansionPotential: 0.02, flexibility: 0.03,
|
||||
visibility: 0.02, footfall: 0.01, talentAccess: 0.05, esg: 0.02, taxEnvironment: 0.00,
|
||||
},
|
||||
confidenceInCriteria: 0.88,
|
||||
extractedFromText: 'Beratungsunternehmen sucht Bürofläche 200–300m² in Bern Zentrum nahe Bahnhof, max. CHF 360/m²/J.',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-05T10:00:00Z',
|
||||
updatedAt: '2026-04-05T10:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
|
||||
{
|
||||
id: 'need-014',
|
||||
companyName: 'Winterthur Handel GmbH',
|
||||
contactName: 'Pascal Brunner',
|
||||
assetType: AssetType.RETAIL,
|
||||
requiredArea: { min: 150, max: 250 },
|
||||
preferredLocations: ['Winterthur', 'Winterthur Zentrum', 'Winterthur Stadthausstrasse', 'Winterthur Altstadt'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 8000, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-06-01',
|
||||
latestMoveIn: '2026-12-01',
|
||||
contractDurationMonths: 36,
|
||||
flexibleTiming: false,
|
||||
},
|
||||
mustCriteriaText: ['Schaufensterfront', 'Erdgeschoss', 'Gute Fussgängerfrequenz'],
|
||||
softFactors: {
|
||||
minPrestige: 70,
|
||||
requireParking: false,
|
||||
maxPublicTransportMinutes: 8,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.12, location: 0.22, budget: 0.14, timing: 0.10,
|
||||
prestige: 0.06, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.03,
|
||||
visibility: 0.12, footfall: 0.12, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
|
||||
},
|
||||
confidenceInCriteria: 0.87,
|
||||
extractedFromText: 'Handelsbetrieb sucht Verkaufsfläche 160–230m² in Winterthur Zentrum, Schaufenster, max. CHF 480/m²/J.',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-03-20T11:00:00Z',
|
||||
updatedAt: '2026-03-20T11:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
|
||||
{
|
||||
id: 'need-015',
|
||||
companyName: 'Luzern Advisory GmbH',
|
||||
contactName: 'Markus Bucher',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 350, max: 600 },
|
||||
preferredLocations: ['Luzern', 'Luzern Zentrum', 'Luzern-West', 'Emmen'],
|
||||
excludedLocations: [],
|
||||
budgetRange: { maxPerSqm: 312, maxMonthlyTotal: 14000, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2026-06-01',
|
||||
latestMoveIn: '2027-01-01',
|
||||
contractDurationMonths: 60,
|
||||
flexibleTiming: true,
|
||||
},
|
||||
mustCriteriaText: ['ÖV < 10 Min', 'Moderner Ausbau', 'Parkplätze vorhanden'],
|
||||
softFactors: {
|
||||
minPrestige: 60,
|
||||
minAccessibility: 70,
|
||||
requireParking: true,
|
||||
maxPublicTransportMinutes: 10,
|
||||
},
|
||||
weightingProfile: {
|
||||
area: 0.19, location: 0.22, budget: 0.18, timing: 0.11,
|
||||
prestige: 0.07, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
|
||||
visibility: 0.02, footfall: 0.01, talentAccess: 0.04, esg: 0.02, taxEnvironment: 0.01,
|
||||
},
|
||||
confidenceInCriteria: 0.86,
|
||||
extractedFromText: 'Beratungsgesellschaft sucht moderne Bürofläche 400–550m² in Luzern, max. CHF 312/m²/J, Parkplätze nötig.',
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-10T14:00:00Z',
|
||||
updatedAt: '2026-04-10T14:00:00Z',
|
||||
},
|
||||
|
||||
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
|
||||
{
|
||||
id: 'need-uc-c',
|
||||
|
||||
+181
-6
@@ -608,7 +608,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 56,
|
||||
accessibility: 88,
|
||||
visibilityScore: 72,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 1,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -830,7 +830,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 60,
|
||||
accessibility: 88,
|
||||
visibilityScore: 75,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 70,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2017,7 +2017,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 48,
|
||||
accessibility: 78,
|
||||
visibilityScore: 68,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 60,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2230,7 +2230,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 58,
|
||||
accessibility: 85,
|
||||
visibilityScore: 70,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 2,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -2373,7 +2373,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 55,
|
||||
accessibility: 82,
|
||||
visibilityScore: 70,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 65,
|
||||
parkingSpots: 0,
|
||||
publicTransportMinutes: 5,
|
||||
@@ -3453,7 +3453,7 @@ export const mockProperties: Property[] = [
|
||||
prestige: 85,
|
||||
accessibility: 95,
|
||||
visibilityScore: 88,
|
||||
passerbyFrequency: 'MEDIUM_HIGH',
|
||||
passerbyFrequency: 'HIGH',
|
||||
talentAccess: 76,
|
||||
parkingSpots: 0,
|
||||
publicTransportMinutes: 3,
|
||||
@@ -4487,8 +4487,183 @@ export const mockProperties: Property[] = [
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DIRECT LISTINGS (6) — created via Inserate-Formular, sourceType DIRECT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'prop-d01',
|
||||
title: 'Bürofläche Bachmattstrasse 28',
|
||||
assetType: AssetType.OFFICE,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
status: 'ACTIVE',
|
||||
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3906, lng: 8.4886 } },
|
||||
address: { street: 'Bachmattstrasse', houseNumber: '28', postalCode: '8048', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 320,
|
||||
rentPricePerSqm: 380,
|
||||
availabilityDate: '2026-06-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.80,
|
||||
dataQuality: { score: 0.78, missingCriticalFields: [], missingOptionalFields: ['softFactors', 'hardFacts'], lastVerifiedAt: '2026-03-10', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
softFactors: { prestige: 60, accessibility: 75, publicTransportMinutes: 8, parkingSpots: 4 },
|
||||
hardFacts: { floor: 3, fitOut: 'BASIC', parking: 4 },
|
||||
floorLevel: 3,
|
||||
ancillaryCosts: 4.2,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Helle Bürofläche im 3. OG an der Bachmattstrasse, Zürich-Altstetten. 320 m², Basisausbau, Parkplätze im Haus. 8 Minuten zur S-Bahn Altstetten.',
|
||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-02-14T09:30:00Z',
|
||||
updatedAt: '2026-02-14T09:30:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'prop-d02',
|
||||
title: 'Gewerbe- & Lagerfläche Pratteln',
|
||||
assetType: AssetType.LIGHT_INDUSTRIAL,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
status: 'ACTIVE',
|
||||
location: { city: 'Pratteln', district: 'Industriezone Nord', canton: 'BL', country: 'CH', coordinates: { lat: 47.5199, lng: 7.6951 } },
|
||||
address: { street: 'Industriestrasse', houseNumber: '14', postalCode: '4133', city: 'Pratteln', country: 'CH' },
|
||||
areaSqm: 580,
|
||||
rentPricePerSqm: 145,
|
||||
availabilityDate: '2026-05-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.85,
|
||||
dataQuality: { score: 0.82, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-04-01', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
softFactors: { prestige: 40, accessibility: 80, parkingSpots: 10 },
|
||||
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 10, ceilingHeightM: 6.5, loadingDocksCount: 2 },
|
||||
floorLevel: 0,
|
||||
ancillaryCosts: 2.8,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Gewerbe- und Lagerhalle im Industriegebiet Pratteln. Erdgeschoss, 580 m², 6,5 m Hallenhöhe, 2 Ladelucken, 10 Aussenparkplätze. Sofort verfügbar.',
|
||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-03-05T11:00:00Z',
|
||||
updatedAt: '2026-03-05T11:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'prop-d03',
|
||||
title: 'Bürofläche Bollwerk 15, Bern',
|
||||
assetType: AssetType.OFFICE,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
status: 'ACTIVE',
|
||||
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9497, lng: 7.4391 } },
|
||||
address: { street: 'Bollwerk', houseNumber: '15', postalCode: '3011', city: 'Bern', country: 'CH' },
|
||||
areaSqm: 240,
|
||||
rentPricePerSqm: 295,
|
||||
availabilityDate: '2026-07-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.78,
|
||||
dataQuality: { score: 0.74, missingCriticalFields: [], missingOptionalFields: ['hardFacts'], lastVerifiedAt: '2026-04-20', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
softFactors: { prestige: 72, accessibility: 92, publicTransportMinutes: 3, parkingSpots: 0 },
|
||||
hardFacts: { floor: 4, fitOut: 'FULL' },
|
||||
floorLevel: 4,
|
||||
ancillaryCosts: 3.5,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Repräsentative Bürofläche im 4. OG am Bollwerk, Bern. 240 m², Vollausbau, Toplage direkt beim Bahnhof Bern (3 Min.). Kein Parkplatz.',
|
||||
images: ['https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-02T14:15:00Z',
|
||||
updatedAt: '2026-04-02T14:15:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'prop-d04',
|
||||
title: 'Verkaufsfläche Stadthausstrasse Winterthur',
|
||||
assetType: AssetType.RETAIL,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
status: 'INACTIVE',
|
||||
location: { city: 'Winterthur', district: 'Zentrum', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4997, lng: 8.7241 } },
|
||||
address: { street: 'Stadthausstrasse', houseNumber: '6', postalCode: '8400', city: 'Winterthur', country: 'CH' },
|
||||
areaSqm: 185,
|
||||
rentPricePerSqm: 360,
|
||||
availabilityDate: '2026-08-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.72,
|
||||
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] },
|
||||
softFactors: { prestige: 68, accessibility: 85, visibilityScore: 80, publicTransportMinutes: 5 },
|
||||
hardFacts: { floor: 0, fitOut: 'BASIC', hasStorefront: true },
|
||||
floorLevel: 0,
|
||||
ancillaryCosts: 5.0,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
description: 'Ladenfläche an der Stadthausstrasse Winterthur, Erdgeschoss, Schaufensterfront. 185 m², Basisausbau. Zentrale Lage, hohe Passantenfrequenz. Derzeit deaktiviert — Verhandlung sistiert.',
|
||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-01-20T10:00:00Z',
|
||||
updatedAt: '2026-04-15T08:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'prop-d05',
|
||||
title: 'Logistikhalle Muttenz Rheinstrasse',
|
||||
assetType: AssetType.LOGISTICS,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
status: 'ACTIVE',
|
||||
location: { city: 'Muttenz', district: 'Frenkendorf Ost', canton: 'BL', country: 'CH', coordinates: { lat: 47.5150, lng: 7.6469 } },
|
||||
address: { street: 'Rheinstrasse', houseNumber: '61', postalCode: '4132', city: 'Muttenz', country: 'CH' },
|
||||
areaSqm: 1200,
|
||||
rentPricePerSqm: 130,
|
||||
availabilityDate: '2026-06-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.88,
|
||||
dataQuality: { score: 0.85, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-05-01', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
softFactors: { prestige: 35, accessibility: 88, parkingSpots: 20 },
|
||||
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 20, ceilingHeightM: 9.0, loadingDocksCount: 4, powerSupplyKva: 400 },
|
||||
floorLevel: 0,
|
||||
ancillaryCosts: 2.2,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Moderne Logistikhalle in Muttenz, 1200 m², 9 m Hallenhöhe, 4 Ladelucken, Stromanschluss 400 kVA. Direkter Autobahnanschluss A2/A3, 20 Aussenparkplätze.',
|
||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2026-04-28T16:00:00Z',
|
||||
updatedAt: '2026-04-28T16:00:00Z',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'prop-d06',
|
||||
title: 'Bürofläche Zürichstrasse 45, Luzern',
|
||||
assetType: AssetType.OFFICE,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
status: 'INACTIVE',
|
||||
location: { city: 'Luzern', district: 'Littau', canton: 'LU', country: 'CH', coordinates: { lat: 47.0530, lng: 8.2592 } },
|
||||
address: { street: 'Zürichstrasse', houseNumber: '45', postalCode: '6014', city: 'Luzern', country: 'CH' },
|
||||
areaSqm: 450,
|
||||
rentPricePerSqm: 260,
|
||||
availabilityDate: '2026-09-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.70,
|
||||
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] },
|
||||
softFactors: { prestige: 55, accessibility: 70, publicTransportMinutes: 12, parkingSpots: 8 },
|
||||
hardFacts: { floor: 1, fitOut: 'BASIC', parking: 8 },
|
||||
floorLevel: 1,
|
||||
ancillaryCosts: 3.8,
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
description: 'Bürofläche Zürichstrasse 45, Luzern-Littau. 450 m², 1. OG, Basisausbau. 8 Tiefgaragenplätze. Deaktiviert — Preisüberarbeitung ausstehend.',
|
||||
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-12-10T08:00:00Z',
|
||||
updatedAt: '2026-03-20T12:00:00Z',
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
// Assign lease contract URLs to ~50 % of properties (every other one)
|
||||
mockProperties.forEach((p, i) => {
|
||||
if (i % 2 !== 0) return
|
||||
const startYear = p.leaseStartDate ? new Date(p.leaseStartDate).getFullYear() : 2020 + (i % 5)
|
||||
const endYear = p.leaseEndDate ? new Date(p.leaseEndDate).getFullYear() : startYear + 5
|
||||
p.leaseContractUrl = `https://docs.wincasa.ch/mietvertraege/${p.id}.pdf`
|
||||
p.leaseContractName = `Mietvertrag ${startYear}–${endYear}`
|
||||
})
|
||||
|
||||
// Assign images sequentially per pool so no two properties ever share the same image
|
||||
// (until pool wraps, which happens after ~12 properties per pool type)
|
||||
const _poolCounters: Record<string, number> = {}
|
||||
|
||||
@@ -4,6 +4,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-001',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 4.2,
|
||||
activePlatformSearches: 14,
|
||||
avgVacancyMonths: 4.2,
|
||||
demandSupplyRatio: 2.3,
|
||||
comparableListings: 3,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-001-1',
|
||||
@@ -44,6 +52,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-007',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 3.8,
|
||||
activePlatformSearches: 11,
|
||||
avgVacancyMonths: 3.5,
|
||||
demandSupplyRatio: 2.8,
|
||||
comparableListings: 1,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-007-1',
|
||||
@@ -83,6 +99,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-008',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 7.1,
|
||||
activePlatformSearches: 7,
|
||||
avgVacancyMonths: 6.0,
|
||||
demandSupplyRatio: 1.4,
|
||||
comparableListings: 4,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-008-1',
|
||||
@@ -123,6 +147,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-012',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Büro',
|
||||
kpis: {
|
||||
vacancyRatePct: 2.9,
|
||||
activePlatformSearches: 22,
|
||||
avgVacancyMonths: 2.8,
|
||||
demandSupplyRatio: 3.4,
|
||||
comparableListings: 2,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-012-1',
|
||||
@@ -172,6 +204,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
|
||||
{
|
||||
propertyId: 'prop-014',
|
||||
generatedAt: '2025-05-18T08:00:00Z',
|
||||
assetTypeLabel: 'Lager / Logistik',
|
||||
kpis: {
|
||||
vacancyRatePct: 5.5,
|
||||
activePlatformSearches: 11,
|
||||
avgVacancyMonths: 5.8,
|
||||
demandSupplyRatio: 1.9,
|
||||
comparableListings: 2,
|
||||
},
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-014-1',
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'luci
|
||||
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { InquiryMessage } from '../../domain/inquiry'
|
||||
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
|
||||
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
|
||||
@@ -32,7 +31,7 @@ export default function Anfragen() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { data: pipelineItems = [] } = usePipelineItems()
|
||||
const { mutate: moveStage } = useMoveStage()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
|
||||
const preselectedId = searchParams.get('inquiry')
|
||||
|
||||
@@ -156,7 +155,7 @@ export default function Anfragen() {
|
||||
<TextField
|
||||
size="small" placeholder="Suchen..." fullWidth
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> }}
|
||||
slotProps={{ input: { startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> } }}
|
||||
sx={{ mb: 1.25 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -41,7 +41,6 @@ export default function Compare() {
|
||||
overallWinnerIdx,
|
||||
bestScoreIdx,
|
||||
worstConfIdx,
|
||||
dqScores,
|
||||
worstDQIdx,
|
||||
missingCriticalCounts,
|
||||
maxMissingCritical,
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
||||
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeft } from 'lucide-react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
||||
import { PropertyMap } from '../../components/shared'
|
||||
import { getCityIntelligence } from '../../lib/locationIntelligence'
|
||||
import {
|
||||
LocationIntelligencePanel,
|
||||
ExecutiveSummaryPanel,
|
||||
NeedAlignmentPanel,
|
||||
ScoreBreakdownPanel,
|
||||
TradeoffPanel,
|
||||
RiskPanel,
|
||||
MissingInformationPanel,
|
||||
FutureAvailabilityContextPanel,
|
||||
NextActionsPanel,
|
||||
} from '../../components/match-detail'
|
||||
import { NextActionsPanel } from '../../components/match-detail'
|
||||
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
||||
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
|
||||
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections'
|
||||
import { MatchDetailScoreBreakdown } from '../../components/match-detail/MatchDetailScoreBreakdown'
|
||||
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -49,7 +37,6 @@ export default function MatchDetail() {
|
||||
const { addToCompare } = useCompareStore()
|
||||
const { openSavedDialog } = usePipelineStore()
|
||||
const { openInquiryDialog } = useInquiryStore()
|
||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||
|
||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||
|
||||
@@ -91,7 +78,7 @@ export default function MatchDetail() {
|
||||
const handleCompare = () => {
|
||||
if (match && !isFuture && property) {
|
||||
addToCompare({
|
||||
resultType: property.resultType ?? 'VERIFIED_PORTFOLIO',
|
||||
resultType: (property.resultType ?? 'VERIFIED_PORTFOLIO') as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
|
||||
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
|
||||
})
|
||||
} else if (match && isFuture && signal) {
|
||||
@@ -210,51 +197,15 @@ export default function MatchDetail() {
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* ── Full analysis toggle ── */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setShowFullAnalysis(v => !v)}
|
||||
fullWidth
|
||||
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||
sx={{
|
||||
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
|
||||
textTransform: 'none', fontWeight: 500,
|
||||
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
|
||||
}}
|
||||
>
|
||||
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
|
||||
</Button>
|
||||
|
||||
{/* ── Full analysis (hidden by default) ── */}
|
||||
{showFullAnalysis && (
|
||||
<>
|
||||
<ExecutiveSummaryPanel match={match} />
|
||||
<NeedAlignmentPanel match={match} need={need} property={property} />
|
||||
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
|
||||
<LocationIntelligencePanel property={property} />
|
||||
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
||||
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
||||
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
<PropertyMap
|
||||
lat={property.location.coordinates.lat}
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={220}
|
||||
{/* ── Full analysis toggle + expanded panels ── */}
|
||||
<MatchDetailScoreBreakdown
|
||||
match={match}
|
||||
property={property}
|
||||
need={need}
|
||||
signal={signal}
|
||||
isFuture={isFuture}
|
||||
taxCalculatorUrl={taxCalculatorUrl}
|
||||
/>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
+361
-179
@@ -1,231 +1,413 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
Drawer,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
|
||||
import { AlertTriangle, CheckCircle2, Clock, Edit2, RefreshCw } from 'lucide-react'
|
||||
import { LoadingPage, ErrorState } from '../../components/ui'
|
||||
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
|
||||
import { PropertyDetailView } from '../../components/supply'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { getRecommendedActions } from '../../services/dataQualityService'
|
||||
import { DataFreshness } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE'
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 0.8) return 'success'
|
||||
if (score >= 0.6) return 'warning'
|
||||
return 'error'
|
||||
type QualityFilter = 'ALL' | 'INCOMPLETE' | 'STALE' | 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const FIELD_LABEL: Record<string, string> = {
|
||||
'Mietpreis/m²': 'Mietpreis',
|
||||
'Fläche m²': 'Fläche',
|
||||
'Verfügbarkeit': 'Verfügbarkeit',
|
||||
'Adresse': 'Adresse',
|
||||
'Beschreibung': 'Beschreibung',
|
||||
'Bilder': 'Bilder',
|
||||
'Ausbaustandard': 'Ausbaustandard',
|
||||
'Soft Factors': 'Soft Factors',
|
||||
'Jahresmiete (CHF)': 'Jahresmiete',
|
||||
'Expansionspotenzial': 'Erweiterung',
|
||||
}
|
||||
|
||||
function priorityOf(p: Property): number {
|
||||
const hasCritical = p.dataQuality.missingCriticalFields.length > 0
|
||||
const isStale = p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
|
||||
if (hasCritical && isStale) return 0
|
||||
if (hasCritical) return 1
|
||||
if (isStale) return 2
|
||||
if (p.dataQuality.score < 0.6) return 3
|
||||
if (p.dataQuality.score < 0.8) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
// ── Row ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PropertyRow = memo(function PropertyRow({
|
||||
property,
|
||||
onEdit,
|
||||
}: {
|
||||
property: Property
|
||||
onEdit: (id: string) => void
|
||||
}) {
|
||||
const q = property.dataQuality
|
||||
const hasCritical = q.missingCriticalFields.length > 0
|
||||
const isStale = q.freshness === DataFreshness.STALE || q.freshness === DataFreshness.OUTDATED
|
||||
const topAction = getRecommendedActions(q, q.freshness)[0] ?? null
|
||||
|
||||
const visibleFields = q.missingCriticalFields.slice(0, 3)
|
||||
const overflow = q.missingCriticalFields.length - visibleFields.length
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onEdit(property.id)}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
|
||||
alignItems: 'center',
|
||||
px: 2, py: 1.25,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
cursor: 'pointer',
|
||||
bgcolor: hasCritical ? '#fff8f8' : 'white',
|
||||
'&:hover': { bgcolor: hasCritical ? '#fff0f0' : '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Objekt */}
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{property.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{property.location.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Score */}
|
||||
<Box>
|
||||
<DataQualityBadge quality={q} showLabel />
|
||||
</Box>
|
||||
|
||||
{/* Fehlende Pflichtfelder */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.4 }}>
|
||||
{hasCritical ? (
|
||||
<>
|
||||
{visibleFields.map(f => (
|
||||
<Chip
|
||||
key={f}
|
||||
label={FIELD_LABEL[f] ?? f}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626', fontWeight: 600 }}
|
||||
/>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Chip
|
||||
label={`+${overflow}`}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Chip
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
label="Vollständig"
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#dcfce7', color: '#16a34a' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Aktualität */}
|
||||
<Box>
|
||||
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
|
||||
</Box>
|
||||
|
||||
{/* Empfehlung */}
|
||||
<Box>
|
||||
{topAction ? (
|
||||
<Tooltip title={topAction.detail}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{topAction.priority === 'HIGH'
|
||||
? <AlertTriangle size={12} color="#dc2626" />
|
||||
: isStale
|
||||
? <Clock size={12} color="#d97706" />
|
||||
: <RefreshCw size={12} color="#64748b" />
|
||||
}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: topAction.priority === 'HIGH' ? '#dc2626' : '#d97706',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.68rem',
|
||||
lineHeight: 1.2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{topAction.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem' }}>—</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Edit button */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Objekt bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEdit(property.id)}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#1e3a5f' } }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ── KPI Card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({
|
||||
label, value, sub, color, active, onClick,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
sub?: string
|
||||
color: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
cursor: 'pointer',
|
||||
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
|
||||
bgcolor: active ? `${color}08` : 'white',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': { borderColor: color, bgcolor: `${color}08` },
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color, lineHeight: 1.2, my: 0.5 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DataQuality() {
|
||||
const [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
|
||||
const [filter, setFilter] = useState<QualityFilter>('ALL')
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
|
||||
const { data: properties = [], isLoading, error } = useProperties()
|
||||
|
||||
if (isLoading) return <LoadingPage />
|
||||
if (error) return <ErrorState />
|
||||
|
||||
const avgScore = properties.length
|
||||
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
|
||||
const total = properties.length
|
||||
|
||||
const avgScore = total
|
||||
? properties.reduce((s, p) => s + p.dataQuality.score, 0) / total
|
||||
: 0
|
||||
|
||||
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
||||
const staleData = properties.filter(
|
||||
const incomplete = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
||||
const stale = properties.filter(
|
||||
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED,
|
||||
)
|
||||
const highQuality = properties.filter(p => p.dataQuality.score >= 0.8)
|
||||
const medQuality = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
|
||||
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6)
|
||||
const high = properties.filter(p => p.dataQuality.score >= 0.8)
|
||||
const medium = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
|
||||
const low = properties.filter(p => p.dataQuality.score < 0.6)
|
||||
|
||||
const filtered = [...properties]
|
||||
.filter(p => {
|
||||
if (!qualityFilter) return true
|
||||
if (qualityFilter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
|
||||
if (qualityFilter === 'HIGH') return p.dataQuality.score >= 0.8 && p.dataQuality.missingCriticalFields.length === 0
|
||||
if (qualityFilter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
|
||||
if (qualityFilter === 'LOW') return p.dataQuality.score < 0.6
|
||||
const filtered = useMemo(() => {
|
||||
const base = [...properties]
|
||||
const result = base.filter(p => {
|
||||
if (filter === 'ALL') return true
|
||||
if (filter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
|
||||
if (filter === 'STALE') return p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
|
||||
if (filter === 'HIGH') return p.dataQuality.score >= 0.8
|
||||
if (filter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
|
||||
if (filter === 'LOW') return p.dataQuality.score < 0.6
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.dataQuality.score - b.dataQuality.score)
|
||||
return result.sort((a, b) => priorityOf(a) - priorityOf(b))
|
||||
}, [properties, filter])
|
||||
|
||||
const scoreColor = avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#dc2626'
|
||||
|
||||
function toggleFilter(f: QualityFilter) {
|
||||
setFilter(prev => prev === f ? 'ALL' : f)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Page Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenpflege</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<Box className="grid grid-cols-3 gap-4">
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Ø Qualitätsscore</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}>
|
||||
{Math.round(avgScore * 100)}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)}
|
||||
sx={{ height: 8, borderRadius: 4, mt: 1 }} />
|
||||
</Card>
|
||||
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Pflichtfelder fehlen</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}>
|
||||
{criticalIssues.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}>
|
||||
{staleData.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Quality Distribution */}
|
||||
<SectionContainer title="Qualitätsverteilung">
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Box className="flex flex-col gap-3">
|
||||
{[
|
||||
{ label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const },
|
||||
{ label: 'Mittel (60–79%)', count: medQuality.length, color: 'warning' as const },
|
||||
{ label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
|
||||
].map(row => (
|
||||
<Box key={row.label} className="flex items-center gap-3">
|
||||
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>{row.label}</Typography>
|
||||
<Chip label={row.count} size="small" color={row.color} />
|
||||
<Box className="flex-1">
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={properties.length > 0 ? (row.count / properties.length) * 100 : 0}
|
||||
color={row.color}
|
||||
sx={{ height: 10, borderRadius: 5 }}
|
||||
{/* KPI cards */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2 }}>
|
||||
<KpiCard
|
||||
label="Ø Qualitätsscore"
|
||||
value={`${Math.round(avgScore * 100)}%`}
|
||||
color={scoreColor}
|
||||
active={filter === 'ALL'}
|
||||
onClick={() => setFilter('ALL')}
|
||||
sub={`${total} Objekte insgesamt`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Pflichtfelder fehlen"
|
||||
value={incomplete.length}
|
||||
sub={`von ${total} Objekten — klicken zum Filtern`}
|
||||
color="#dc2626"
|
||||
active={filter === 'INCOMPLETE'}
|
||||
onClick={() => toggleFilter('INCOMPLETE')}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Veraltete Daten"
|
||||
value={stale.length}
|
||||
sub={`von ${total} Objekten — klicken zum Filtern`}
|
||||
color="#d97706"
|
||||
active={filter === 'STALE'}
|
||||
onClick={() => toggleFilter('STALE')}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
||||
{properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}%
|
||||
|
||||
{/* Quality distribution */}
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Qualitätsverteilung</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{[
|
||||
{ label: 'Hoch (≥80%)', count: high.length, color: '#16a34a', f: 'HIGH' as QualityFilter },
|
||||
{ label: 'Mittel (60–79%)', count: medium.length, color: '#d97706', f: 'MEDIUM' as QualityFilter },
|
||||
{ label: 'Niedrig (<60%)', count: low.length, color: '#dc2626', f: 'LOW' as QualityFilter },
|
||||
].map(row => (
|
||||
<Box
|
||||
key={row.label}
|
||||
onClick={() => toggleFilter(row.f)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 2, cursor: 'pointer',
|
||||
p: 0.75, borderRadius: 1,
|
||||
bgcolor: filter === row.f ? `${row.color}10` : 'transparent',
|
||||
'&:hover': { bgcolor: `${row.color}08` },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ width: 130, flexShrink: 0, fontWeight: filter === row.f ? 700 : 400 }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={row.count}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem', fontWeight: 700, bgcolor: `${row.color}18`, color: row.color, flexShrink: 0 }}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={total > 0 ? (row.count / total) * 100 : 0}
|
||||
sx={{
|
||||
height: 10, borderRadius: 5,
|
||||
bgcolor: `${row.color}18`,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: row.color, borderRadius: 5 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', width: 36, textAlign: 'right', flexShrink: 0 }}>
|
||||
{total > 0 ? Math.round((row.count / total) * 100) : 0}%
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</SectionContainer>
|
||||
|
||||
{/* Objects Table */}
|
||||
<SectionContainer title="Objektübersicht">
|
||||
{/* Filter bar */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={qualityFilter}
|
||||
onChange={e => setQualityFilter(e.target.value as QualityFilter)}
|
||||
displayEmpty
|
||||
sx={{ fontSize: '0.8125rem', minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">Alle Qualitätsstufen</MenuItem>
|
||||
<MenuItem value="HIGH">Hoch (≥80%)</MenuItem>
|
||||
<MenuItem value="MEDIUM">Mittel (60–79%)</MenuItem>
|
||||
<MenuItem value="LOW">Niedrig ({'<'}60%)</MenuItem>
|
||||
<MenuItem value="INCOMPLETE">Pflichtfelder fehlen</MenuItem>
|
||||
</Select>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filtered.length} von {properties.length} Objekten
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Qualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Pflichtfelder</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Nächste Massnahme</Typography></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map(property => {
|
||||
const q = property.dataQuality
|
||||
const hasCritical = q.missingCriticalFields.length > 0
|
||||
const actions = getRecommendedActions(q, q.freshness)
|
||||
const topAction = actions[0] ?? null
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={property.id}
|
||||
hover
|
||||
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.03)' } : {}}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.25 }}>{property.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{property.location.city}</Typography>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<DataQualityBadge quality={q} showLabel />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{q.missingCriticalFields.length === 0 ? (
|
||||
<Chip label="Vollständig" color="success" size="small" />
|
||||
) : (
|
||||
<Tooltip
|
||||
title={q.missingCriticalFields.join(', ')}
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
label={`${q.missingCriticalFields.length} fehlen`}
|
||||
color="error"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{topAction ? (
|
||||
{/* Object list */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: topAction.priority === 'HIGH' ? '#c0392b' : '#d97706' }}>
|
||||
{topAction.label}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, flex: 1 }}>Objektübersicht</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filtered.length} von {total} Objekten · sortiert nach Handlungsbedarf
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
{topAction.detail}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">—</Typography>
|
||||
{filter !== 'ALL' && (
|
||||
<Chip
|
||||
label={`Filter: ${filter}`}
|
||||
size="small"
|
||||
onDelete={() => setFilter('ALL')}
|
||||
sx={{ height: 22, fontSize: '0.68rem' }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<Card sx={{ overflow: 'hidden' }}>
|
||||
{/* Table header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
|
||||
px: 2, py: 1,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{['Objekt', 'Qualität', 'Fehlende Pflichtfelder', 'Aktualität', 'Empfehlung', ''].map(h => (
|
||||
<Typography key={h} variant="caption" sx={{ fontWeight: 700, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||||
{h}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{filtered.map(p => (
|
||||
<PropertyRow key={p.id} property={p} onEdit={setDetailId} />
|
||||
))}
|
||||
</Card>
|
||||
</SectionContainer>
|
||||
) : (
|
||||
<Alert severity="success" sx={{ mt: 1 }}>
|
||||
Keine Objekte in dieser Kategorie — alles in Ordnung!
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Edit drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
|
||||
>
|
||||
{detailId && (
|
||||
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} />
|
||||
)}
|
||||
</Drawer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Divider,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart2,
|
||||
Briefcase,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Newspaper,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useMarketLeads } from '../../hooks/useMarketLeads'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||
import { DecisionContextPanel } from '../../components/ui'
|
||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
// ── Source meta ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={12} /> },
|
||||
PRESS: { label: 'Pressebericht', icon: <Newspaper size={12} /> },
|
||||
COMPANY_REPORT: { label: 'Geschäftsbericht',icon: <FileText size={12} /> },
|
||||
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={12} /> },
|
||||
}
|
||||
|
||||
const QUALITY_META: Record<string, { label: string; color: string }> = {
|
||||
HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success },
|
||||
MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning },
|
||||
LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error },
|
||||
}
|
||||
|
||||
function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' {
|
||||
return probability >= 0.70 ? 'HIGH' : probability >= 0.50 ? 'MEDIUM' : 'LOW'
|
||||
}
|
||||
|
||||
// ── Property match chip ───────────────────────────────────────────────────────
|
||||
|
||||
function PropertyMatchRow({ p }: { p: Property }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Box
|
||||
onClick={() => navigate('/supply/properties')}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 1.25, py: 0.75, borderRadius: 1,
|
||||
bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`,
|
||||
cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg },
|
||||
}}
|
||||
>
|
||||
<Building2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.success, flexShrink: 0 }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Lead card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function LeadCard({ lead }: { lead: MarketLead }) {
|
||||
const { signal, matchingProperties } = lead
|
||||
const probPct = Math.round(signal.probability * 100)
|
||||
const qual = signalQuality(signal.probability)
|
||||
const qualMeta = QUALITY_META[qual]
|
||||
const sourceMeta = SOURCE_META[signal.source.type] ?? null
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5, mb: 2,
|
||||
borderLeft: `3px solid ${DS_MARKET_SIGNAL.accent}`,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
|
||||
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
|
||||
<Target size={18} color={DS_MARKET_SIGNAL.accent} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25, mb: 0.25 }}>
|
||||
{signal.companyName ?? signal.locationHint}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{signal.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Chip
|
||||
label="Market Signal"
|
||||
size="small"
|
||||
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700, fontSize: '0.68rem', flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Key facts strip */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1.75 }}>
|
||||
{signal.areaSqmEstimate && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Flächenbedarf</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Zeithorizont</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: qualMeta.color }}>{probPct}%</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Standort</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{signal.locationHint}</Typography>
|
||||
</Box>
|
||||
{sourceMeta && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Quelle</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{sourceMeta.icon}
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.75 }} />
|
||||
|
||||
{/* Market indicators */}
|
||||
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
|
||||
<Box sx={{ mb: 1.75 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||
Erkannte Nachfragesignale
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{signal.marketIndicators.map((ind, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<TrendingUp size={11} color={DS_MARKET_SIGNAL.accent} style={{ marginTop: 3, flexShrink: 0 }} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{ind}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confirmed / unconfirmed facts */}
|
||||
{((signal.confirmedFacts?.length ?? 0) > 0 || (signal.unconfirmedFacts?.length ?? 0) > 0) && (
|
||||
<Box sx={{ mb: 1.75 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||
{(signal.confirmedFacts ?? []).map((f, i) => (
|
||||
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.success, fontWeight: 500 }}>{f}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{(signal.unconfirmedFacts ?? []).map((f, i) => (
|
||||
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<AlertCircle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.warning, fontWeight: 500 }}>{f}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
{/* Portfolio matches */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||
Passende Objekte im Portfolio ({matchingProperties.length})
|
||||
</Typography>
|
||||
{matchingProperties.length === 0 ? (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic' }}>
|
||||
Kein passendes Portfolioobjekt gefunden — manuell prüfen empfohlen
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{matchingProperties.slice(0, 3).map(p => (
|
||||
<PropertyMatchRow key={p.id} p={p} />
|
||||
))}
|
||||
{matchingProperties.length > 3 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}>
|
||||
+{matchingProperties.length - 3} weitere Objekte
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BG.subtle}`, lineHeight: 1.4 }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Loading skeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
function LeadSkeleton() {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2, borderLeft: `3px solid ${DS_BORDER.default}` }}>
|
||||
<Skeleton variant="text" width="60%" height={24} sx={{ mb: 0.5 }} />
|
||||
<Skeleton variant="text" width="80%" height={18} sx={{ mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1, mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={64} sx={{ borderRadius: 1 }} />
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MarketLeads() {
|
||||
const { data: leads, isLoading } = useMarketLeads()
|
||||
|
||||
const leadsWithMatch = leads.filter(l => l.matchingProperties.length > 0)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`, flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Users size={20} color={DS_MARKET_SIGNAL.accent} />
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Markt-Leads</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Erkannte Unternehmen mit aktivem Flächenbedarf — potenzielle Mieter für Ihr Portfolio
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isLoading && (
|
||||
<Chip
|
||||
label={`${leads.length} aktive Leads`}
|
||||
size="small"
|
||||
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
|
||||
|
||||
{/* Decision context */}
|
||||
{!isLoading && leads.length > 0 && (
|
||||
<DecisionContextPanel
|
||||
decision="Welche Unternehmen könnten als Nachmieter für Ihre Objekte in Frage kommen?"
|
||||
context={`${leads.length} erkannte Nachfragesignale · ${leadsWithMatch.length} mit passendem Portfolioobjekt`}
|
||||
metrics={[
|
||||
{ label: 'aktive Markt-Leads', value: leads.length, severity: 'positive' },
|
||||
...(leadsWithMatch.length > 0
|
||||
? [{ label: 'mit passendem Objekt', value: leadsWithMatch.length, severity: 'positive' as const }]
|
||||
: []),
|
||||
]}
|
||||
risks={leads.length - leadsWithMatch.length > 0
|
||||
? [`${leads.length - leadsWithMatch.length} Leads ohne direkten Portfolio-Match — Leerstandsanalyse empfohlen`]
|
||||
: []
|
||||
}
|
||||
actions={[]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LeadSkeleton />
|
||||
<LeadSkeleton />
|
||||
<LeadSkeleton />
|
||||
</>
|
||||
) : leads.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Target size={32} color={DS_TEXT.disabled} style={{ marginBottom: 12 }} />
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>Keine aktiven Markt-Leads gefunden.</Typography>
|
||||
<Typography variant="caption" color="text.disabled">Signale werden laufend aus öffentlichen Quellen erkannt.</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
leads.map(lead => <LeadCard key={lead.signal.id} lead={lead} />)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+337
-89
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Alert,
|
||||
@@ -10,14 +10,21 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
|
||||
import { useMatchesByProperty } from '../../hooks/useMatches'
|
||||
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
|
||||
import { PropertyDetailView } from '../../components/supply'
|
||||
import { PublicNeedDetail, OfferWizard } from '../../components/anfragencenter'
|
||||
import { NEED_TO_LATENT_NEED } from '../../lib/needToLatentNeedMap'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Match } from '../../domain/match'
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro',
|
||||
@@ -28,11 +35,276 @@ const ASSET_LABELS: Record<string, string> = {
|
||||
MIXED: 'Gemischt',
|
||||
}
|
||||
|
||||
const GRID_COLS = '2fr 1fr 80px 100px 110px 120px 80px 48px'
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return '–'
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function matchColor(score: number): string {
|
||||
if (score >= 80) return '#1a7a4a'
|
||||
if (score >= 60) return '#d97706'
|
||||
return '#dc2626'
|
||||
}
|
||||
|
||||
// ─── Suchabo dialog ────────────────────────────────────────────────────────
|
||||
|
||||
function MatchListItem({
|
||||
match,
|
||||
onSelect,
|
||||
}: {
|
||||
match: Match
|
||||
onSelect: (needId: string) => void
|
||||
}) {
|
||||
const latentNeedId = NEED_TO_LATENT_NEED[match.needId] ?? null
|
||||
const { data: latentNeed } = useLatentNeedById(latentNeedId)
|
||||
const companyName = latentNeed?.tenantCompany ?? match.needId
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2, py: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'white',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{companyName}
|
||||
</Typography>
|
||||
{latentNeed && (
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{latentNeed.desiredLocation}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Chip
|
||||
label={`${match.matchScore}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 700,
|
||||
bgcolor: `${matchColor(match.matchScore)}18`,
|
||||
color: matchColor(match.matchScore),
|
||||
}}
|
||||
/>
|
||||
{latentNeedId ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => onSelect(match.needId)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
}}
|
||||
>
|
||||
Anschreiben
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip title="Kein öffentliches Suchabo verknüpft">
|
||||
<span>
|
||||
<Button size="small" disabled sx={{ textTransform: 'none', fontSize: '0.75rem' }}>
|
||||
Anschreiben
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface SuchaboDialogProps {
|
||||
propertyId: string
|
||||
matches: Match[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function SuchaboDialogContent({ matches }: SuchaboDialogProps) {
|
||||
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(
|
||||
matches.length === 1 ? matches[0].needId : null,
|
||||
)
|
||||
const latentNeedId = selectedNeedId ? (NEED_TO_LATENT_NEED[selectedNeedId] ?? null) : null
|
||||
const { data: latentNeed, isLoading } = useLatentNeedById(latentNeedId)
|
||||
|
||||
const showList = !selectedNeedId
|
||||
|
||||
if (showList) {
|
||||
return (
|
||||
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{matches.map(m => (
|
||||
<MatchListItem key={m.id} match={m} onSelect={setSelectedNeedId} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!latentNeed) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">Suchabo nicht gefunden.</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{matches.length > 1 && (
|
||||
<Box sx={{ px: 2, pt: 1.5 }}>
|
||||
<Button
|
||||
startIcon={<ArrowLeft size={14} />}
|
||||
size="small"
|
||||
onClick={() => setSelectedNeedId(null)}
|
||||
sx={{ textTransform: 'none', color: '#64748b', fontWeight: 500 }}
|
||||
>
|
||||
Alle Suchabos
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<PublicNeedDetail need={latentNeed} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Row ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RowProps {
|
||||
p: Property
|
||||
isLast: boolean
|
||||
onDetail: (id: string) => void
|
||||
onToggleStatus: (p: Property) => void
|
||||
onDelete: (p: Property) => void
|
||||
onChipClick: (propertyId: string, matches: Match[]) => void
|
||||
updatePending: boolean
|
||||
removePending: boolean
|
||||
}
|
||||
|
||||
const ListingRow = memo(function ListingRow({
|
||||
p, isLast, onDetail, onToggleStatus, onDelete, onChipClick, updatePending, removePending,
|
||||
}: RowProps) {
|
||||
const { data: matches = [] } = useMatchesByProperty(p.id)
|
||||
const topScore = matches.length > 0 ? Math.max(...matches.map(m => m.matchScore)) : 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onDetail(p.id)}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
px: 2, py: 1.25,
|
||||
alignItems: 'center',
|
||||
borderBottom: isLast ? 'none' : '1px solid #f1f5f9',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Title + type */}
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={ASSET_LABELS[p.assetType] ?? p.assetType}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', mt: 0.25, bgcolor: '#f1f5f9', color: '#475569' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* City */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.location.city}
|
||||
</Typography>
|
||||
|
||||
{/* Area */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
{/* Rent */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
|
||||
</Typography>
|
||||
|
||||
{/* Match count */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
{matches.length > 0 ? (
|
||||
<Tooltip title="Suchabos anzeigen und anschreiben">
|
||||
<Chip
|
||||
label={`${matches.length} Suchabo${matches.length !== 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
onClick={() => onChipClick(p.id, matches)}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: `${matchColor(topScore)}18`,
|
||||
color: matchColor(topScore),
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>—</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Created */}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{formatDate(p.createdAt)}
|
||||
</Typography>
|
||||
|
||||
{/* Status toggle */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.status === 'ACTIVE'}
|
||||
onChange={() => onToggleStatus(p)}
|
||||
disabled={updatePending}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Delete */}
|
||||
<Box onClick={e => e.stopPropagation()}>
|
||||
<Tooltip title="Inserat löschen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDelete(p)}
|
||||
disabled={removePending}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MyListings() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -40,26 +312,25 @@ export default function MyListings() {
|
||||
const updateProperty = useUpdateProperty()
|
||||
const removeProperty = useRemoveProperty()
|
||||
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [suchaboDialog, setSuchaboDialog] = useState<{
|
||||
propertyId: string
|
||||
matches: Match[]
|
||||
} | null>(null)
|
||||
|
||||
function handleToggleStatus(p: Property) {
|
||||
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
updateProperty.mutate(
|
||||
{ id: p.id, input: { status: next } },
|
||||
{
|
||||
onError: () => {
|
||||
setActionError('Status konnte nicht geändert werden.')
|
||||
},
|
||||
},
|
||||
{ onError: () => setActionError('Status konnte nicht geändert werden.') },
|
||||
)
|
||||
}
|
||||
|
||||
function handleDelete(p: Property) {
|
||||
removeProperty.mutate(p.id, {
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null)
|
||||
},
|
||||
onSuccess: () => setConfirmDelete(null),
|
||||
onError: () => {
|
||||
setActionError('Inserat konnte nicht gelöscht werden.')
|
||||
setConfirmDelete(null)
|
||||
@@ -68,10 +339,10 @@ export default function MyListings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 960, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ maxWidth: 1040, mx: 'auto', px: 3, py: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Meine Inserate</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Direkt erstellte Inserate — unabhängig vom Portfolio
|
||||
</Typography>
|
||||
@@ -116,98 +387,72 @@ export default function MyListings() {
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
||||
gridTemplateColumns: GRID_COLS,
|
||||
px: 2, py: 1,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
}}>
|
||||
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Erstellt', 'Aktiv', ''].map(h => (
|
||||
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Suchabos', 'Erstellt', 'Aktiv', ''].map(h => (
|
||||
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||||
{h}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{listings.map((p, i) => {
|
||||
const isLast = i === listings.length - 1
|
||||
return (
|
||||
<Box
|
||||
{listings.map((p, i) => (
|
||||
<ListingRow
|
||||
key={p.id}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px',
|
||||
px: 2, py: 1.25,
|
||||
alignItems: 'center',
|
||||
borderBottom: isLast ? 'none' : '1px solid #f1f5f9',
|
||||
'&:hover': { bgcolor: '#f8fafc' },
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Title + type */}
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a', lineHeight: 1.3 }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={ASSET_LABELS[p.assetType] ?? p.assetType}
|
||||
size="small"
|
||||
sx={{ height: 16, fontSize: '0.6rem', mt: 0.25, bgcolor: '#f1f5f9', color: '#475569' }}
|
||||
p={p}
|
||||
isLast={i === listings.length - 1}
|
||||
onDetail={setDetailId}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={setConfirmDelete}
|
||||
onChipClick={(pid, m) => setSuchaboDialog({ propertyId: pid, matches: m })}
|
||||
updatePending={updateProperty.isPending}
|
||||
removePending={removeProperty.isPending}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* City */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.location.city}
|
||||
</Typography>
|
||||
|
||||
{/* Area */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
|
||||
{/* Rent */}
|
||||
<Typography variant="body2" sx={{ color: '#374151' }}>
|
||||
CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
|
||||
</Typography>
|
||||
|
||||
{/* Created */}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{formatDate(p.createdAt)}
|
||||
</Typography>
|
||||
|
||||
{/* Status toggle */}
|
||||
<Box>
|
||||
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.status === 'ACTIVE'}
|
||||
onChange={() => handleToggleStatus(p)}
|
||||
disabled={updateProperty.isPending}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Delete */}
|
||||
<Box>
|
||||
<Tooltip title="Inserat löschen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setConfirmDelete(p)}
|
||||
disabled={removeProperty.isPending}
|
||||
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{/* Detail / edit drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
|
||||
>
|
||||
{detailId && (
|
||||
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} />
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Suchabo dialog */}
|
||||
<Dialog
|
||||
open={!!suchaboDialog}
|
||||
onClose={() => setSuchaboDialog(null)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{ paper: { sx: { maxHeight: '85vh' } } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem', pb: 1 }}>
|
||||
Suchabos
|
||||
{suchaboDialog && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 400 }}>
|
||||
{suchaboDialog.matches.length} Suchabo{suchaboDialog.matches.length !== 1 ? 's' : ''} matchen dieses Inserat
|
||||
</Typography>
|
||||
)}
|
||||
</DialogTitle>
|
||||
{suchaboDialog && (
|
||||
<SuchaboDialogContent
|
||||
propertyId={suchaboDialog.propertyId}
|
||||
matches={suchaboDialog.matches}
|
||||
onClose={() => setSuchaboDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!confirmDelete} onClose={() => setConfirmDelete(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Inserat löschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -229,6 +474,9 @@ export default function MyListings() {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* OfferWizard — triggered by PublicNeedDetail's "Angebot erstellen" button */}
|
||||
<OfferWizard />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { Box, Button, Chip, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { DecisionContextPanel } from '../../components/ui'
|
||||
import { ViewToggle } from '../../components/shared'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||
@@ -56,6 +56,9 @@ export default function Properties() {
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
const [headerOpen, setHeaderOpen] = useState(() =>
|
||||
localStorage.getItem('props-header-open') !== 'false'
|
||||
)
|
||||
|
||||
const { data: properties = [], isLoading, isError } = useProperties()
|
||||
|
||||
@@ -103,54 +106,92 @@ export default function Properties() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Collapse toggle strip */}
|
||||
<Box
|
||||
onClick={() => {
|
||||
const next = !headerOpen
|
||||
setHeaderOpen(next)
|
||||
localStorage.setItem('props-header-open', String(next))
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 3,
|
||||
py: 0.5,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
'&:hover': { bgcolor: '#f1f5f9' },
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: '#94a3b8', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
||||
Filter & Übersicht
|
||||
</Typography>
|
||||
{headerOpen ? <ChevronUp size={12} color="#94a3b8" /> : <ChevronDown size={12} color="#94a3b8" />}
|
||||
</Box>
|
||||
|
||||
<Collapse in={headerOpen}>
|
||||
{!isLoading && properties.length > 0 && (
|
||||
<DecisionContextPanel
|
||||
decision="Welche Objekte sind matchbereit — und wo blockieren Datenlücken Matches?"
|
||||
context="Objekte mit fehlenden Pflichtfeldern oder Konfidenz < 55% erscheinen im Match-Center nicht oder mit niedrigem Rang."
|
||||
metrics={[
|
||||
{ label: 'matchbereit', value: matchReady.length, severity: matchReady.length > 0 ? 'positive' : 'warning' },
|
||||
...(criticalGaps.length > 0
|
||||
? [{ label: 'kritische Datenlücken', value: criticalGaps.length, severity: 'critical' as const }]
|
||||
: []
|
||||
),
|
||||
...(lowConfidence.length > 0
|
||||
? [{ label: 'Konfidenz < 55%', value: lowConfidence.length, severity: 'warning' as const }]
|
||||
: []
|
||||
),
|
||||
...(staleOrOutdated.length > 0
|
||||
? [{ label: 'veraltete Daten', value: staleOrOutdated.length, severity: 'warning' as const }]
|
||||
: []
|
||||
),
|
||||
]}
|
||||
missing={allMissingFields.length > 0
|
||||
? [`Fehlende Pflichtfelder bei ${criticalGaps.length} Objekten: ${allMissingFields.join(', ')}`]
|
||||
: []
|
||||
}
|
||||
risks={[
|
||||
...(criticalGaps.length > 0
|
||||
? [`${criticalGaps.length} Objekte werden potenziellen Mietern nicht angezeigt`]
|
||||
: []
|
||||
),
|
||||
...(staleOrOutdated.length > 0
|
||||
? [`${staleOrOutdated.length} Objekte mit veralteten Preisen oder Verfügbarkeiten`]
|
||||
: []
|
||||
),
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: 'Datenpflege starten',
|
||||
primary: criticalGaps.length > 0 || staleOrOutdated.length > 0,
|
||||
onClick: () => navigate('/supply/data-quality'),
|
||||
},
|
||||
]}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 0.75,
|
||||
bgcolor: '#f8fafc',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
borderLeft: '3px solid #1e3a5f',
|
||||
flexShrink: 0,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`${matchReady.length} matchbereit`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600,
|
||||
bgcolor: matchReady.length > 0 ? '#f0fdf4' : '#fef9c3',
|
||||
color: matchReady.length > 0 ? '#1a7a4a' : '#92400e' }}
|
||||
/>
|
||||
{criticalGaps.length > 0 && (
|
||||
<Chip
|
||||
label={`${criticalGaps.length} kritische Datenlücken`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef2f2', color: '#991b1b' }}
|
||||
/>
|
||||
)}
|
||||
{staleOrOutdated.length > 0 && (
|
||||
<Chip
|
||||
label={`${staleOrOutdated.length} veraltete Daten`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef9c3', color: '#92400e' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant={criticalGaps.length > 0 || staleOrOutdated.length > 0 ? 'contained' : 'outlined'}
|
||||
onClick={() => navigate('/supply/data-quality')}
|
||||
sx={criticalGaps.length > 0 || staleOrOutdated.length > 0
|
||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
|
||||
: { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
|
||||
}
|
||||
>
|
||||
Datenpflege
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
</Collapse>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 1.5, p: 1.5 }}>
|
||||
{filtered.map(p => (
|
||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
|
||||
))}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function SupplyDashboard() {
|
||||
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
||||
|
||||
{/* KPI strip */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' }, divideX: true }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' } }}>
|
||||
{/* Starke Matches */}
|
||||
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||
|
||||
export interface IMarktHinweisProvider {
|
||||
getAll(): Promise<MarktHinweis[]>
|
||||
getById(id: string): Promise<MarktHinweis | null>
|
||||
create(data: CreateMarktHinweisInput): Promise<MarktHinweis>
|
||||
update(id: string, data: Partial<MarktHinweis>): Promise<MarktHinweis>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IMarktHinweisProvider } from './IMarktHinweisProvider'
|
||||
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||
import { marktHinweise as seed } from '../mock-data/marktHinweise'
|
||||
|
||||
const store: MarktHinweis[] = [...seed]
|
||||
|
||||
export const MockupMarktHinweisProvider: IMarktHinweisProvider = {
|
||||
async getAll() {
|
||||
return [...store]
|
||||
},
|
||||
|
||||
async getById(id: string) {
|
||||
return store.find(h => h.id === id) ?? null
|
||||
},
|
||||
|
||||
async create(data: CreateMarktHinweisInput) {
|
||||
const next: MarktHinweis = {
|
||||
...data,
|
||||
id: `mh-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'Thomas Müller',
|
||||
verwaltungId: 'v-001',
|
||||
verwaltungName: 'Wincasa AG',
|
||||
}
|
||||
store.push(next)
|
||||
return next
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<MarktHinweis>) {
|
||||
const idx = store.findIndex(h => h.id === id)
|
||||
if (idx === -1) throw new Error(`MarktHinweis ${id} not found`)
|
||||
const updated: MarktHinweis = { ...store[idx], ...data }
|
||||
store[idx] = updated
|
||||
return updated
|
||||
},
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe('futureSignalService', () => {
|
||||
const result = await futureSignalService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
})
|
||||
|
||||
it('each signal has required fields', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('matchService', () => {
|
||||
const result = await matchService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
})
|
||||
|
||||
it('each match has required fields', async () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ describe('needService', () => {
|
||||
const result = await needService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta.page).toBe(1)
|
||||
expect(result.meta.hasMore).toBe(false)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
expect(result.meta!.page).toBe(1)
|
||||
expect(result.meta!.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('each need has required fields', async () => {
|
||||
@@ -49,6 +49,9 @@ describe('needService', () => {
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const },
|
||||
organizationId: 'org-test',
|
||||
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||
confidenceInCriteria: 0.8,
|
||||
}
|
||||
const result = await needService.create(input)
|
||||
expect(result.data.companyName).toBe('Test GmbH')
|
||||
@@ -79,6 +82,9 @@ describe('needService', () => {
|
||||
preferredLocations: ['Bern'],
|
||||
budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const },
|
||||
organizationId: 'org-test',
|
||||
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
|
||||
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
|
||||
confidenceInCriteria: 0.8,
|
||||
})
|
||||
const id = created.data.id
|
||||
await needService.remove(id)
|
||||
|
||||
@@ -7,8 +7,8 @@ describe('propertyService', () => {
|
||||
const result = await propertyService.getAll()
|
||||
expect(Array.isArray(result.data)).toBe(true)
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
expect(result.meta.total).toBe(result.data.length)
|
||||
expect(result.meta.hasMore).toBe(false)
|
||||
expect(result.meta!.total).toBe(result.data.length)
|
||||
expect(result.meta!.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('each property has required fields', async () => {
|
||||
@@ -62,9 +62,14 @@ describe('propertyService', () => {
|
||||
assetType: 'LOGISTICS' as const,
|
||||
resultType: 'VERIFIED_PORTFOLIO' as const,
|
||||
location: { city: 'Basel', country: 'CH' as const },
|
||||
address: { street: 'Teststr.', houseNumber: '1', postalCode: '4000', city: 'Basel', country: 'CH' },
|
||||
areaSqm: 500,
|
||||
rentPricePerSqm: 120,
|
||||
availabilityDate: '2025-01-01',
|
||||
availabilityStatus: 'AVAILABLE_NOW' as const,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.8,
|
||||
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH' as const, warnings: [] },
|
||||
organizationId: 'org-test',
|
||||
}
|
||||
const result = await propertyService.create(input)
|
||||
|
||||
@@ -73,8 +73,8 @@ describe('MockAIService.generateFollowUpQuestions', () => {
|
||||
assetType: 'OFFICE',
|
||||
areaRange: { min: 300, max: 600 },
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 400 },
|
||||
timing: { earliestMoveIn: '2025-09-01' },
|
||||
budgetRange: { maxPerSqm: 400, currency: 'CHF' },
|
||||
timing: { earliestMoveIn: '2025-09-01', latestMoveIn: '2026-09-01', flexibleTiming: false },
|
||||
mustHaveCriteria: ['ÖV-Anbindung'],
|
||||
}
|
||||
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
|
||||
@@ -116,7 +116,7 @@ describe('MockAIService provenance', () => {
|
||||
describe('MockAIService.summarizeTradeOffs', () => {
|
||||
it('returns LOW risk when no HIGH severity trade-offs', async () => {
|
||||
const tradeoffs: TradeOffInput[] = [
|
||||
{ concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' },
|
||||
{ criterion: 'Lage', concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' },
|
||||
]
|
||||
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
||||
expect(result.data.overallRisk).toBe('LOW')
|
||||
@@ -124,7 +124,7 @@ describe('MockAIService.summarizeTradeOffs', () => {
|
||||
|
||||
it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => {
|
||||
const tradeoffs: TradeOffInput[] = [
|
||||
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||
{ criterion: 'Fläche', concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||
]
|
||||
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
||||
expect(result.data.overallRisk).toBe('MEDIUM')
|
||||
@@ -132,8 +132,8 @@ describe('MockAIService.summarizeTradeOffs', () => {
|
||||
|
||||
it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => {
|
||||
const tradeoffs: TradeOffInput[] = [
|
||||
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||
{ concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
|
||||
{ criterion: 'Fläche', concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
|
||||
{ criterion: 'Budget', concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
|
||||
]
|
||||
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
|
||||
expect(result.data.overallRisk).toBe('HIGH')
|
||||
@@ -155,6 +155,7 @@ describe('MockAIService.generateMatchExplanation', () => {
|
||||
propertyCity: 'Zürich',
|
||||
positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }],
|
||||
negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }],
|
||||
needSummary: 'Bürofläche 200–400 m² in Zürich',
|
||||
}
|
||||
|
||||
it('generates STARK headline for score >= 78', async () => {
|
||||
|
||||
@@ -4,16 +4,11 @@ import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
|
||||
import type {
|
||||
IAIService,
|
||||
AIResponse,
|
||||
DecisionBrief,
|
||||
ComparisonSummary,
|
||||
CriteriaExtractionResult,
|
||||
OfferEmailPayload,
|
||||
MatchExplanationInput,
|
||||
MatchExplanation,
|
||||
TradeOffInput,
|
||||
TradeOffSummary,
|
||||
DataQualityInput,
|
||||
DataQualitySummary,
|
||||
MarketSignalClassification,
|
||||
} from '../IAIService'
|
||||
import { mockProvenance } from '../IAIService'
|
||||
|
||||
@@ -316,7 +316,7 @@ export const OpenRouterAIService: IAIService = {
|
||||
preferredLocations: ai.preferredLocations,
|
||||
budgetRange: ai.budgetRange ?? undefined,
|
||||
timing: ai.timing
|
||||
? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined }
|
||||
? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
|
||||
: undefined,
|
||||
mustHaveCriteria: ai.mustHaveCriteria,
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export const aiTraceStore = new AITraceStore()
|
||||
// window.__aiTraces.stats() → summary statistics
|
||||
// window.__aiTraces.clear() → clear all
|
||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||
;(window as Record<string, unknown>).__aiTraces = aiTraceStore
|
||||
;(window as unknown as Record<string, unknown>).__aiTraces = aiTraceStore
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MockupMarktHinweisProvider } from '../provider/MockupMarktHinweisProvider'
|
||||
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
|
||||
|
||||
const provider = MockupMarktHinweisProvider
|
||||
|
||||
export const marktHinweisService = {
|
||||
getAll: (): Promise<MarktHinweis[]> => provider.getAll(),
|
||||
getById: (id: string): Promise<MarktHinweis | null> => provider.getById(id),
|
||||
create: (data: CreateMarktHinweisInput): Promise<MarktHinweis> => provider.create(data),
|
||||
update: (id: string, data: Partial<MarktHinweis>): Promise<MarktHinweis> => provider.update(id, data),
|
||||
}
|
||||
@@ -175,8 +175,8 @@ export const matchService = {
|
||||
rentPricePerSqm: p.rentPricePerSqm,
|
||||
matchScore: score,
|
||||
imageUrl: p.images?.[0],
|
||||
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean),
|
||||
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description
|
||||
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.criterion ?? '').filter(Boolean),
|
||||
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.concern
|
||||
?? (match?.negativeFactors ?? [])[0]?.explanation
|
||||
?? undefined,
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MockupReminderProvider } from '../provider/MockupReminderProvider'
|
||||
import type { Reminder } from '../domain/reminder'
|
||||
import { ReminderPriority, ReminderStatus, ShadowMarketRisk } from '../domain/reminder'
|
||||
import { ReminderPriority, ReminderStatus, ReminderType } from '../domain/reminder'
|
||||
|
||||
const provider = MockupReminderProvider
|
||||
|
||||
@@ -27,27 +27,23 @@ export const reminderService = {
|
||||
|
||||
const urgentCount = active.filter(r => r.priority === ReminderPriority.URGENT).length
|
||||
|
||||
const endOfWeek = new Date(MOCK_TODAY)
|
||||
endOfWeek.setDate(endOfWeek.getDate() + 7)
|
||||
// Counts match getHorizon() in ReminderFeed — exclusive, non-overlapping buckets
|
||||
const dueThisWeek = active.filter(r => {
|
||||
const d = daysDiff(r.dueDate)
|
||||
return d >= 0 && d <= 7
|
||||
return d >= 1 && d <= 7
|
||||
}).length
|
||||
|
||||
const endOfMonth = new Date(MOCK_TODAY)
|
||||
endOfMonth.setDate(endOfMonth.getDate() + 30)
|
||||
const dueThisMonth = active.filter(r => {
|
||||
const d = daysDiff(r.dueDate)
|
||||
return d >= 0 && d <= 30
|
||||
return d >= 8 && d <= 30
|
||||
}).length
|
||||
|
||||
const schattenmarktReadyCount = reminders.filter(
|
||||
r =>
|
||||
r.status === ReminderStatus.ACTIVE &&
|
||||
!r.schattenmarktEnabled &&
|
||||
(r.shadowMarketRisk === ShadowMarketRisk.HIGH),
|
||||
const schattenmarktReadyCount = active.filter(
|
||||
r => r.type === ReminderType.SCHATTENMARKT_RELEASE,
|
||||
).length
|
||||
|
||||
const overdueCount = active.filter(r => daysDiff(r.dueDate) <= 0).length
|
||||
|
||||
const activeDays = active
|
||||
.filter(r => daysDiff(r.dueDate) > 0)
|
||||
.map(r => daysDiff(r.dueDate))
|
||||
@@ -60,6 +56,7 @@ export const reminderService = {
|
||||
dueThisWeek,
|
||||
dueThisMonth,
|
||||
schattenmarktReadyCount,
|
||||
overdueCount,
|
||||
avgDaysToAction,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,22 +6,27 @@ const PROFILES: Record<string, WeightProfile> = {
|
||||
OFFICE: {
|
||||
area: 0.20, location: 0.25, budget: 0.20, timing: 0.15,
|
||||
prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
LOGISTICS: {
|
||||
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
||||
prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
RETAIL: {
|
||||
area: 0.15, location: 0.30, budget: 0.20, timing: 0.10,
|
||||
prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
PRODUCTION: {
|
||||
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
|
||||
prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
DEFAULT: {
|
||||
area: 0.25, location: 0.25, budget: 0.20, timing: 0.15,
|
||||
prestige: 0.07, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.01,
|
||||
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Attachment } from '../domain/inquiry'
|
||||
import type { ReportObjectFieldSelection } from '../domain/inquiryReport'
|
||||
|
||||
export type OfferStep = 'select_properties' | 'pdf_review' | 'checked' | 'send'
|
||||
export type OfferStep = 'select_properties' | 'select_fields' | 'pdf_review' | 'checked' | 'send'
|
||||
|
||||
interface OfferWizardState {
|
||||
isOpen: boolean
|
||||
@@ -11,6 +12,7 @@ interface OfferWizardState {
|
||||
currentStep: OfferStep
|
||||
offerDraftId: string | null
|
||||
editableFields: Record<string, string>
|
||||
fieldSelections: ReportObjectFieldSelection[]
|
||||
pdfPreviewReady: boolean
|
||||
messageDraft: string
|
||||
messageSubject: string
|
||||
@@ -22,6 +24,8 @@ interface OfferWizardState {
|
||||
setStep(step: OfferStep): void
|
||||
setOfferDraftId(id: string): void
|
||||
updateField(fieldId: string, value: string): void
|
||||
setFieldSelections(selections: ReportObjectFieldSelection[]): void
|
||||
updateFieldSelection(propertyId: string, selection: ReportObjectFieldSelection): void
|
||||
setPdfReady(): void
|
||||
setMessageDraft(text: string): void
|
||||
setMessageSubject(s: string): void
|
||||
@@ -38,6 +42,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
|
||||
currentStep: 'select_properties',
|
||||
offerDraftId: null,
|
||||
editableFields: {},
|
||||
fieldSelections: [],
|
||||
pdfPreviewReady: false,
|
||||
messageDraft: '',
|
||||
messageSubject: '',
|
||||
@@ -51,6 +56,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
|
||||
currentStep: 'select_properties',
|
||||
offerDraftId: null,
|
||||
editableFields: {},
|
||||
fieldSelections: [],
|
||||
pdfPreviewReady: false,
|
||||
messageDraft: '',
|
||||
messageSubject: '',
|
||||
@@ -68,6 +74,11 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
|
||||
setOfferDraftId: (id) => set({ offerDraftId: id }),
|
||||
updateField: (fieldId, value) =>
|
||||
set((s) => ({ editableFields: { ...s.editableFields, [fieldId]: value } })),
|
||||
setFieldSelections: (fieldSelections) => set({ fieldSelections }),
|
||||
updateFieldSelection: (propertyId, selection) =>
|
||||
set((s) => ({
|
||||
fieldSelections: s.fieldSelections.map(fs => fs.propertyId === propertyId ? selection : fs),
|
||||
})),
|
||||
setPdfReady: () => set({ pdfPreviewReady: true }),
|
||||
setMessageDraft: (messageDraft) => set({ messageDraft }),
|
||||
setMessageSubject: (messageSubject) => set({ messageSubject }),
|
||||
@@ -83,6 +94,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
|
||||
currentStep: 'select_properties',
|
||||
offerDraftId: null,
|
||||
editableFields: {},
|
||||
fieldSelections: [],
|
||||
pdfPreviewReady: false,
|
||||
messageDraft: '',
|
||||
messageSubject: '',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ReminderType, ReminderStatus, ReminderPriority } from '../domain/reminder'
|
||||
|
||||
export type FilterHorizon = 'ALL' | 'OVERDUE' | 'THIS_WEEK' | 'THIS_MONTH' | 'LATER'
|
||||
|
||||
interface ReminderStore {
|
||||
selectedId: string | null
|
||||
setSelectedId: (id: string | null) => void
|
||||
@@ -8,10 +10,12 @@ interface ReminderStore {
|
||||
setDrawerOpen: (open: boolean) => void
|
||||
filterType: ReminderType | 'ALL'
|
||||
setFilterType: (t: ReminderType | 'ALL') => void
|
||||
filterStatus: ReminderStatus | 'ALL'
|
||||
setFilterStatus: (s: ReminderStatus | 'ALL') => void
|
||||
filterStatus: ReminderStatus | 'ALL' | 'ACTIVE'
|
||||
setFilterStatus: (s: ReminderStatus | 'ALL' | 'ACTIVE') => void
|
||||
filterPriority: ReminderPriority | 'ALL'
|
||||
setFilterPriority: (p: ReminderPriority | 'ALL') => void
|
||||
filterHorizon: FilterHorizon
|
||||
setFilterHorizon: (h: FilterHorizon) => void
|
||||
searchQuery: string
|
||||
setSearchQuery: (q: string) => void
|
||||
viewMode: 'list' | 'card'
|
||||
@@ -25,10 +29,12 @@ export const useReminderStore = create<ReminderStore>((set) => ({
|
||||
setDrawerOpen: (open) => set({ drawerOpen: open }),
|
||||
filterType: 'ALL',
|
||||
setFilterType: (t) => set({ filterType: t }),
|
||||
filterStatus: 'ALL',
|
||||
filterStatus: 'ACTIVE',
|
||||
setFilterStatus: (s) => set({ filterStatus: s }),
|
||||
filterPriority: 'ALL',
|
||||
setFilterPriority: (p) => set({ filterPriority: p }),
|
||||
filterHorizon: 'ALL',
|
||||
setFilterHorizon: (h) => set({ filterHorizon: h }),
|
||||
searchQuery: '',
|
||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||
viewMode: 'list',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"buildCommand": "npx vite build",
|
||||
"outputDirectory": "dist",
|
||||
"framework": null,
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||
}
|
||||
Reference in New Issue
Block a user