21 Commits

Author SHA1 Message Date
Benjamin Sutter c6d3071010 docs: Änderungsprotokoll aktualisiert — Change 2 vollständig, Change 5 + 6 ergänzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:24:17 +02:00
Benjamin Sutter 5a1f412ce3 feat: anonymes Suchprofil — Toggle im Speichern-Schritt, supply-side Masking
- Toggle 'Anonymes Suchprofil' im AISearchSavePreview (nicht in Suchmaske)
- Box wechselt zu lila wenn aktiv, Text erklärt was anonymisiert wird
- isAnonymous wird via buildNeedInput in gespeichertes Need geschrieben
- MatchListCard + NeedSelectionPanel: zeigen 'Anonyme Anfrage' (lila) statt Firmenname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:04:17 +02:00
Benjamin Sutter b2530f9e20 feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6):
- FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte
- FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J.
- Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k)
- FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts
- BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface)

Must-have Kriterien (Nicht prüfbar Fix):
- ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft
- Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft
- Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM

fitOut-Datenpflege:
- fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL)
- MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:09:55 +02:00
Benjamin Sutter a825672197 feat: collapsible filter bar, compact property cards, offer wizard field selection
- Properties page: collapsible decision/filter strip (localStorage state), slim
  single-row Datenpflege status bar (chips + button), 4-column card grid
- PropertyIntelligenceCard: reduced image height (160→120px), tighter body padding
- OfferWizard (latente Anfragen): new "Felder wählen" step between property
  selection and PDF preview; uses ReportObjectFieldSelector per property
- MockPdfPreview: adds second PDF page showing selected properties side by side
  with photo, title, location and all chosen hard/soft fact fields
- offerWizardStore: adds select_fields step type and fieldSelections state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:36:32 +02:00
Benjamin Sutter 36570c5bdc fix: resolve all TypeScript errors and refactor oversized components
- Fix MUI v9 API: PaperProps/InputLabelProps/inputProps → slotProps in 6 components
- Add ExternalMarketResult alias, PropertyUnit import, OPERATIONS workspace config
- Fix TradeOff.description → .concern, ScoreFactor.label → .criterion
- Make schattenmarktRelease.leadTimeMonths optional, fix mock-data enum values
- Fix useMatchDetailData query typing, weightingService missing WeightProfile keys
- Split Pipeline/Compare/MatchDetail/IntelligenceMatchCard into sub-components
- Fix all test fixtures (CreateNeedInput, CreatePropertyInput, TradeOffInput, etc.)
- Add vercel.json for deployment, zero tsc errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:08:35 +02:00
Benjamin Sutter 98d2770054 feat: Datenpflege — actionable work queue with inline editing
- KPI cards now clickable filters (Pflichtfelder fehlen → filters list, etc.)
- Quality distribution bars also clickable filters
- Table rows sorted by urgency: critical+stale first, then critical, then stale
- Missing field names shown as red chips (not tooltip count): Mietpreis, Fläche, etc.
- Each row has Edit2 icon button + full row click → opens PropertyDetailView drawer
- Empfehlung column: top recommended action with priority icon (AlertTriangle/Clock)
- Active filter chip with × to clear
- DataFreshness stale highlighting in row background

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:57:12 +02:00
Benjamin Sutter 6fb2fb027c refactor: rename tab to KI-Signale, diversify DEMAND signal narratives
- "Digitale Signale" → "KI-Signale" tab label
- signal-001: Alpbach Advisors — new Zürich branch announced via press release
- signal-007: EBH Treuhand — Handelsregister merger of two Winterthur firms
- signal-010: Centra Schweiz — suburban→city relocation for talent attraction
- signal-013: Rheintaler Logistik Solutions — spin-off needs its own premises
- signal-015: BernTech — CEO publicly confirmed new office in VC press release
- signal-004 improved: lab expansion driven by published strategic plan, not headcount
- Each signal uses a different trigger: press/Handelsregister/annual report/VC round

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:53:30 +02:00
Benjamin Sutter 7f6b7766e6 feat: Marktchancen — Netzwerk-Hinweise tab (human-sourced market intel)
- MarktHinweis domain type: SUCHE/VERFUEGBARKEIT direction, INTERN/PLATTFORM
  visibility, anonymization flag, source tracking
- Full data layer: IMarktHinweisProvider, MockupMarktHinweisProvider, service, hooks
- 7 seed entries: mix of intern/shared, demand/supply, own/partner Verwaltungen
- Marktchancen gets two-tab layout: Digitale Signale (crawler) + Netzwerk (human)
- HinweisListItem: direction icon, visibility badge (Intern/Geteilt/partner name)
- HinweisDetail: note card, portfolio match (SUCHE) or availability info (VERFUEGBARKEIT)
- HinweisErfassenDialog: direction toggle, asset type, location, area range,
  company + anonymize switch, source, note, card-based visibility picker
- HinweisPropertyCard: same Anschreiben composer pattern as crawler side

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:47:50 +02:00
Benjamin Sutter 63909c8caf feat: Marktchancen — source links, KI-Kontaktdaten, portfolio match, Anschreiben
- ExtractedContact domain type (ContactType, ContactConfidence) on FutureSignal
- Mock extractedContacts for all 6 DEMAND signals (website, email, phone/linkedin)
- Source URL row with external link button
- KI-Kontaktdaten section: clickable links, confidence dots, purple accent
- PropertyMatchCard: area fit % chip (green/yellow/red), inline Anschreiben toggle
- buildAnschreiben() pre-filled German outreach template, editable + copy button
- Portfolio match section groups properties by fit quality

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:32:42 +02:00
Benjamin Sutter d8ff380d6e feat: merge Markt-Leads + Markt Intelligence → Marktchancen
Two-pane UX (from Market Intelligence) with supply-relevant data
(FutureSignal + portfolio property matches from Markt-Leads):
- Left pane: filterable signal list with probability badge + portfolio match count
- Right pane: company overview, KI-Analyse, market indicators, Faktencheck,
  portfolio matches (click → Meine Objekte), Reminder erstellen action
- No OPS-level actions (approve/reject/review stripped out)
- Nav: remove Markt-Leads entry, rename to Marktchancen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:22:18 +02:00
Benjamin Sutter 81d66d24a9 feat: hide Marktsignale tab on Inserate detail drawer — direct listings have no market signal data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:14:29 +02:00
Benjamin Sutter e597e81ff5 feat: hide Matchability tab on Inserate detail drawer
PropertyDetailView gets hideTabs prop — MyListings passes ['Matchability']
since the Suchabo chip already covers the match/contact use case inline.
Tab content now keys by name not index, so removal doesn't shift panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:13:50 +02:00
Benjamin Sutter d2af8664f4 fix: KPI card counts match feed — exclusive buckets + filter reset on click
- reminderService: dueThisWeek/dueThisMonth use exclusive day ranges (1-7,
  8-30) matching getHorizon() buckets; schattenmarktReadyCount counts by
  type=SCHATTENMARKT_RELEASE (same criteria as the feed filter)
- ReminderKpiBar: clicking a horizon card resets filterType to ALL;
  clicking Pre-Market card resets filterHorizon to ALL — ensures the number
  shown on the card always equals the number of rows displayed below

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:12:08 +02:00
Benjamin Sutter cd220ed282 feat: Suchabo inline contact flow — chip opens need detail + OfferWizard
- Add >80% STRONG matches for all 6 direct listings (match-072–075)
- Add 4 new Needs (need-012–015) tailored to each previously unmatched listing
- Add 8 new LatentNeeds (lneed-007–014) for all needs in direct listing matches
- Add need→latentNeed mapping (src/lib/needToLatentNeedMap.ts)
- MyListings: clicking Suchabo chip opens dialog with company name, score,
  PublicNeedDetail view and OfferWizard — no navigation to Anfragencenter needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:08:41 +02:00
Benjamin Sutter a9ee53f185 feat: Inserate — edit drawer + Suchabo match count per listing
Each listing row now shows a color-coded Suchabo chip (green ≥80 / amber
≥60 / red) with a tooltip listing individual scores. Clicking a row opens
a right-side PropertyDetailView drawer with full edit + Matchability tab
(which shows the matching Suchabos). Added 7 mock matches across the 6
direct listings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:54:06 +02:00
Benjamin Sutter fe8444c1d3 feat: add 6 mock direct listings (sourceType DIRECT) for Inserate page
4 active + 2 inactive — Office/Retail/Light-Industrial/Logistics across
Zürich, Bern, Winterthur, Pratteln, Muttenz, Luzern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:47:12 +02:00
Benjamin Sutter 453f2b1982 refactor: merge nav — rename to Inserate, remove Neues Inserat sidebar entry
New listings are created via the button on the Inserate page, not as a
first-class nav destination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:45:14 +02:00
Benjamin Sutter d804d1c923 feat: Reminder Manager UX — timeline-first grouped layout
Replace 3-row filter panel with a grouped feed (Überfällig / Diese Woche /
Dieser Monat / Später). KPI cards are now clickable shortcuts that filter to
their time horizon or type (Pre-Market). Filter bar condenses to one row:
search + type dropdown + archive toggle. Rows get a left priority-color
border (red/orange/yellow). Default status filter is ACTIVE+SNOOZED.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:42:37 +02:00
Benjamin Sutter a9e038a64f feat: Marktsignale KPI panel with adjustable radius (5/10/15/20 km)
Adds MarketKpis domain type with formula-based radius scaling, a MarketKpiPanel
component (vacancy rate, platform searches, avg vacancy duration, demand/supply
ratio — color-coded with interpretation badges), and integrates it at the top of
PropertyMarketSignalsTab. Demand/supply section labels now update with the
selected radius. Mock data covers all 5 existing market reports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:28:04 +02:00
Benjamin Sutter 61a26d00a7 feat: Mietvertrag-Mock für 50 % der Objekte (URL + Bezeichnung)
Jedes zweite Objekt bekommt per Post-Processing-Loop eine
leaseContractUrl (docs.wincasa.ch/…) + leaseContractName
(„Mietvertrag YYYY–YYYY") — ohne echten Inhalt, nur für die UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:19:37 +02:00
Benjamin Sutter f2edc3b2ef feat: Mietvertrag hinterlegen pro Objekt (Link + Bezeichnung)
- Property.leaseContractUrl? + leaseContractName? im Domain
- PropertyDetailOverview: Mietvertrag-Block in "Miet- & Mieterinformationen"
  — View-Modus: Öffnen-Button oder "Noch kein Vertrag"-Hinweis
  — Edit-Modus: URL-Feld + Bezeichnungsfeld (gespeichert via updateProperty)
- ReminderDetailDrawer: Mietvertrag-Link im "Objekt"-Abschnitt
  (lädt Property über usePropertyById)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:18:02 +02:00
117 changed files with 6430 additions and 1927 deletions
+3 -1
View File
@@ -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/**)"
]
}
}
+11
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
Binary file not shown.
+1 -3
View File
@@ -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')}` : 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) {
+16 -10
View File
@@ -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,12 +51,14 @@ export function OfferWizard() {
fullScreen={fullScreen}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
height: fullScreen ? '100vh' : '85vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
slotProps={{
paper: {
sx: {
height: fullScreen ? '100vh' : '85vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
},
}}
>
@@ -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>
)
})
+40 -2
View File
@@ -1,4 +1,4 @@
import { Alert, Box, Button, CircularProgress } from '@mui/material'
import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, Typography } from '@mui/material'
import { Save } from 'lucide-react'
import { NeedCardPreview } from './NeedCardPreview'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
@@ -10,7 +10,9 @@ interface Props {
needTitle: string
overallConfidence: number
isSaving: boolean
isAnonymous: boolean
onNeedTitleChange: (t: string) => void
onAnonymousChange: (v: boolean) => void
onBack: () => void
onSave: () => void
}
@@ -22,7 +24,9 @@ export function AISearchSavePreview({
needTitle,
overallConfidence,
isSaving,
isAnonymous,
onNeedTitleChange,
onAnonymousChange,
onBack,
onSave,
}: Props) {
@@ -39,7 +43,41 @@ export function AISearchSavePreview({
needTitle={needTitle}
onNeedTitleChange={onNeedTitleChange}
/>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
{/* Anonymity option */}
<Box sx={{
maxWidth: 720, mx: 'auto', mt: 2,
p: 1.5, borderRadius: 1.5, border: '1px solid',
borderColor: isAnonymous ? '#7c3aed' : '#e2e8f0',
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
transition: 'all 0.15s',
}}>
<FormControlLabel
control={
<Switch
size="small"
checked={isAnonymous}
onChange={e => onAnonymousChange(e.target.checked)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' },
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: isAnonymous ? '#6d28d9' : 'text.primary' }}>
Anonymes Suchprofil
</Typography>
<Typography variant="caption" sx={{ color: isAnonymous ? '#7c3aed' : 'text.secondary' }}>
Firmenname wird nicht an Vermieter übermittelt Verwalter sehen nur Branche und Flächenbedarf
</Typography>
</Box>
}
/>
</Box>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
Zurück
</Button>
@@ -1,4 +1,4 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
@@ -73,7 +73,46 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 200"
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
/>
</Box>
</Box>
{/* Divisibility */}
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid #f1f5f9' }}>
<FormControlLabel
control={
<Switch
size="small"
checked={c.requiresDivisibility ?? false}
onChange={e => set({ ...c, requiresDivisibility: e.target.checked || undefined })}
/>
}
label={<Typography variant="caption" sx={{ fontWeight: 600 }}>Fläche muss teilbar sein</Typography>}
sx={{ mb: c.requiresDivisibility ? 1 : 0 }}
/>
{c.requiresDivisibility && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindesteinheit (m²)</Typography>
<TextField
size="small" type="number"
placeholder="z.B. 150"
value={c.minDivisibleUnit ?? ''}
onChange={e => set({ ...c, minDivisibleUnit: parseInt(e.target.value) || undefined })}
sx={{ width: 160 }}
slotProps={{ htmlInput: { min: 10, max: 5000, step: 10 } }}
/>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
)
+48 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
const RADIUS_MARKS = [5, 10, 20, 30, 50, 100].map(v => ({ value: v, label: v === 5 || v === 100 ? `${v} km` : `${v}` }))
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
))}
</Stack>
)}
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
{(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) === 0 && <Box sx={{ mb: 1.5 }} />}
{/* Radius */}
<Box sx={{ mb: 2.5, px: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>Suchradius</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
{c.searchRadius ?? 30} km
</Typography>
</Box>
<Slider
value={c.searchRadius ?? 30}
min={1}
max={100}
marks={RADIUS_MARKS}
step={1}
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
size="small"
sx={{ color: '#1e3a5f', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
/>
</Box>
{/* Budget */}
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
@@ -220,6 +242,30 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack>
)}
{/* Anonymous search */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e2e8f0'}` }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
Anonyme Suche
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
Firmenname wird nicht an Vermieter übermittelt
</Typography>
</Box>
<FormControlLabel
control={
<Switch
size="small"
checked={c.isAnonymous ?? false}
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
/>
}
label=""
sx={{ m: 0 }}
/>
</Box>
<NeedExtendedRequirements criteria={c} onChange={set} />
</Card>
)
+14 -6
View File
@@ -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>
)
}
})
+30 -1
View File
@@ -115,6 +115,35 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
{vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
)}
{vm.fitOutLabel && (
<Chip
label={vm.fitOutLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutViable ? '#059669' : '#94a3b8',
color: vm.fitOutViable ? '#059669' : '#64748b',
}}
/>
)}
{vm.fitOutInvestment && !vm.fitOutInvestment.isFullyCovered && (
<Chip
label={vm.fitOutInvestment.shortLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutInvestment.netTotal.max > 100000 ? '#d97706' : '#94a3b8',
color: vm.fitOutInvestment.netTotal.max > 100000 ? '#92400e' : '#64748b',
}}
/>
)}
{vm.isDivisible && vm.minDivisibleUnitSqm && (
<Chip
label={`Teilbar ab ${vm.minDivisibleUnitSqm}`}
size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: '#7c3aed', color: '#7c3aed' }}
/>
)}
{topRisk && (
<Chip
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
@@ -182,7 +211,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
{vm.tradeoffs[0].description}
{vm.tradeoffs[0].concern}
</Typography>
</Box>
)}
@@ -1,6 +1,7 @@
import type { ResultType } from '../../domain/enums'
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
import type { PropertyUnit } from '../../domain/property'
import type { FitOutInvestment } from '../../lib/fitOutUtils'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
signalIsControlled?: boolean
signalAreaSqmEstimate?: number
// Divisibility (Teilbarkeit)
isDivisible?: boolean
minDivisibleUnitSqm?: number
// Fit-out signal
fitOutLabel?: string // e.g. "FULL — bezugsfertig", "BASIC + CHF 200 MAB"
fitOutViable?: boolean // true when budget bridges the gap
fitOutInvestment?: FitOutInvestment
// Unit-first display (primary)
propertySubtitle?: string // building name shown below the unit title
+1
View File
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
export { MatchActionToolbar } from './MatchActionToolbar'
export { MatchCardSkeleton } from './MatchCardSkeleton'
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
export type {
MatchCardViewModel,
MatchCardVariant,
@@ -70,9 +70,12 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{need && (
<Chip
label={need.companyName}
label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }}
sx={need.isAnonymous
? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' }
: { bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }
}
/>
)}
<Chip
@@ -32,9 +32,14 @@ export function NeedSelectionPanel({ matches }: Props) {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3, flex: 1, mr: 0.5 }}>
{need.companyName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, mr: 0.5, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }} noWrap>
{need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
</Typography>
{need.isAnonymous && (
<Chip label="Anonym" size="small" sx={{ bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 9, height: 16, fontWeight: 700, flexShrink: 0, border: '1px solid #ddd6fe' }} />
)}
</Box>
{pendingCount > 0 && (
<Chip label={pendingCount} size="small"
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
@@ -0,0 +1,132 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = 5
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
function chfRange(min: number, max: number): string {
if (Math.round(min) === Math.round(max)) return chf(min)
return `${chf(min)} ${chf(max)}`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
return (
<Box sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
py: 0.875, borderBottom: isTotal ? 'none' : `1px solid ${DS_BORDER.default}`,
bgcolor: isTotal ? (isWarning ? DS_SURFACE.warning.bg : DS_SURFACE.success.bg) : 'transparent',
px: isTotal ? 1.5 : 0, mx: isTotal ? -1.5 : 0, borderRadius: isTotal ? 1 : 0,
}}>
<Box>
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 400 }}>{label}</Typography>
{sub && <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>{sub}</Typography>}
</Box>
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 500, color: isTotal ? DS_TEXT.primary : DS_TEXT.secondary, flexShrink: 0, ml: 2 }}>
{value}
</Typography>
</Box>
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.'
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, fontSize: 10, height: 20, border: `1px solid ${DS_SURFACE.blue.border}` }}
/>
)}
</Box>
<Box sx={{ px: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden' }}>
<Row
label="Jahresmiete"
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{isReadyToMoveIn ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : (
<Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
isTotal
isWarning={isWarning}
/>
</Box>
{disclaimer && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
{disclaimer}
</Typography>
)}
</Paper>
)
}
@@ -19,6 +19,10 @@ interface MatchDetailHeroProps {
keyFacts: Array<{ label: string; value: string }>
onCompare: () => void
onShortlist: () => void
searchCenterLat?: number
searchCenterLng?: number
searchRadiusKm?: number
searchLabel?: string
}
export function MatchDetailHero({
@@ -32,6 +36,10 @@ export function MatchDetailHero({
keyFacts,
onCompare,
onShortlist,
searchCenterLat,
searchCenterLng,
searchRadiusKm,
searchLabel,
}: MatchDetailHeroProps) {
return (
<>
@@ -51,6 +59,10 @@ export function MatchDetailHero({
lng={property.location.coordinates.lng}
label={property.title}
height={340}
searchCenterLat={searchCenterLat}
searchCenterLng={searchCenterLng}
searchRadiusKm={searchRadiusKm}
searchLabel={searchLabel}
/>
) : null
)}
@@ -1,11 +1,11 @@
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { Building2, Clock, ExternalLink, HardHat, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useMatchDetail } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -63,6 +63,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />}
{property.hardFacts?.fitOut && (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return <KeyFactRow label="Ausbaustandard" value={mab ? `${base} + CHF ${mab}/m² MAB` : base} />
})()}
</Paper>
{/* Eigenschaften */}
@@ -0,0 +1,100 @@
import { memo, useState } from 'react'
import { Box, Button, Paper, Typography } from '@mui/material'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { PropertyMap } from '../shared'
import {
ExecutiveSummaryPanel,
NeedAlignmentPanel,
LocationIntelligencePanel,
TradeoffPanel,
RiskPanel,
MissingInformationPanel,
FutureAvailabilityContextPanel,
ScoreBreakdownPanel,
} from '.'
import { MatchDetailPropertySections } from './MatchDetailPropertySections'
import { useMatchDetail } from '../../hooks/useMatches'
import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need'
import type { FutureSignal } from '../../domain/futureSignal'
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
import { lookupCityCoords } from '../../lib/locationIntelligence'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
interface Props {
match: Match
property: Property | null
need: Need | null
signal: FutureSignal | null
isFuture: boolean
taxCalculatorUrl?: string
}
export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown({
match,
property,
need,
signal,
isFuture,
taxCalculatorUrl,
}: Props) {
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
const searchLabel = need?.preferredLocations?.[0]
return (
<>
{/* ── Full analysis toggle ── */}
<Button
variant="outlined"
onClick={() => setShowFullAnalysis(v => !v)}
fullWidth
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
sx={{
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
textTransform: 'none', fontWeight: 500,
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
}}
>
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
</Button>
{/* ── Full analysis (hidden by default) ── */}
{showFullAnalysis && (
<>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
<LocationIntelligencePanel property={property} />
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
searchCenterLat={searchCenter?.lat}
searchCenterLng={searchCenter?.lng}
searchRadiusKm={need?.searchRadius}
searchLabel={searchLabel}
/>
</Paper>
)}
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
</>
)}
</>
)
})
@@ -11,7 +11,6 @@ import {
ExternalLink,
Info,
Layers,
ShieldCheck,
Tag,
Train,
TrendingUp,
@@ -1,5 +1,5 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react'
import { Banknote, Calendar, HardHat, MapPin, Maximize2, Tag } from 'lucide-react'
import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -42,6 +42,16 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '' },
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '' },
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '' },
...(property?.hardFacts?.fitOut ? [{
icon: <HardHat size={15} />,
label: 'Ausbaustandard',
value: (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return mab ? `${base} + CHF ${mab}/m² MAB` : base
})(),
}] : []),
]
return (
+3
View File
@@ -1,5 +1,7 @@
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
export { MatchDetailHeader } from './MatchDetailHeader'
export { MatchDetailHero } from './MatchDetailHero'
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
@@ -10,4 +12,5 @@ export { MissingInformationPanel } from './MissingInformationPanel'
export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
<TextField
label="Fläche (m²)" value={areaSqm}
onChange={e => onAreaSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/>
<TextField
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
onChange={e => onRentPerSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/>
<TextField
@@ -1,4 +1,4 @@
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
interface Props {
@@ -10,6 +10,12 @@ interface Props {
onParkingChange: (v: string) => void
ceilingHeight: string
onCeilingHeightChange: (v: string) => void
mieterausbaubeitrag: string
onMieterausbaubeitragChange: (v: string) => void
isFlexible: boolean
onIsFlexibleChange: (v: boolean) => void
minLettableSqm: string
onMinLettableSqmChange: (v: string) => void
}
export function TechnicalDetailsSection({
@@ -17,6 +23,9 @@ export function TechnicalDetailsSection({
fitOut, onFitOutChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
@@ -35,17 +44,59 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField
label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)}
size="small" type="number" inputProps={{ min: 0 }} fullWidth
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
/>
<TextField
label="Deckenhöhe (m)" value={ceilingHeight}
onChange={e => onCeilingHeightChange(e.target.value)}
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
/>
</Box>
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
control={
<Switch
checked={isFlexible}
onChange={e => onIsFlexibleChange(e.target.checked)}
size="small"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Fläche ist teilbar</Typography>
<Typography variant="caption" color="text.secondary">
Mieter können auch nur einen Teil der Fläche mieten
</Typography>
</Box>
}
sx={{ m: 0, flex: 1 }}
/>
{isFlexible && (
<TextField
label="Mindesteinheit (m²)"
value={minLettableSqm}
onChange={e => onMinLettableSqmChange(e.target.value)}
size="small"
type="number"
sx={{ width: 180 }}
slotProps={{ htmlInput: { min: 10, step: 10 } }}
helperText="Kleinste vermietbare Einheit"
/>
)}
</Box>
</Card>
)
}
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
open={open}
maxWidth="sm"
fullWidth
disableEscapeKeyDown
>
<DialogContent sx={{ pt: 4, pb: 2 }}>
{SLIDES[activeStep]}
+5
View File
@@ -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'
+77 -16
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { Box } from '@mui/material'
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
import { Box, Typography } from '@mui/material'
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
@@ -12,11 +12,21 @@ L.Icon.Default.mergeOptions({
shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
})
const searchIcon = L.divIcon({
className: '',
html: `<div style="width:14px;height:14px;border-radius:50%;background:#7c3aed;border:3px solid white;box-shadow:0 2px 6px rgba(124,58,237,0.5)"></div>`,
iconAnchor: [7, 7],
})
interface Props {
lat: number
lng: number
label?: string
height?: number
searchCenterLat?: number
searchCenterLng?: number
searchRadiusKm?: number
searchLabel?: string
}
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
@@ -27,34 +37,85 @@ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
return null
}
export function PropertyMap({ lat, lng, label, height = 260 }: Props) {
function FitToRadius({ propLat, propLng, searchLat, searchLng, radiusKm }: {
propLat: number; propLng: number
searchLat: number; searchLng: number; radiusKm: number
}) {
const map = useMap()
useEffect(() => {
const circleBounds = L.latLng(searchLat, searchLng).toBounds(radiusKm * 1000 * 2)
const bounds = circleBounds.extend([propLat, propLng])
map.fitBounds(bounds, { padding: [30, 30] })
}, [map, propLat, propLng, searchLat, searchLng, radiusKm])
return null
}
export function PropertyMap({ lat, lng, label, height = 260, searchCenterLat, searchCenterLng, searchRadiusKm, searchLabel }: Props) {
const hasSearch = searchCenterLat !== undefined && searchCenterLng !== undefined && searchRadiusKm !== undefined
return (
<Box
sx={{
height,
width: '100%',
overflow: 'hidden',
flexShrink: 0,
// Ensure leaflet controls appear above MUI elements
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
'& .leaflet-control': { zIndex: 1 },
}}
>
<Box sx={{ position: 'relative', height, width: '100%', overflow: 'hidden', flexShrink: 0,
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
'& .leaflet-control': { zIndex: 1 },
}}>
<MapContainer
center={[lat, lng]}
zoom={14}
zoom={hasSearch ? 10 : 14}
scrollWheelZoom
style={{ height: '100%', width: '100%' }}
>
<RecenterOnChange lat={lat} lng={lng} />
{hasSearch
? <FitToRadius propLat={lat} propLng={lng} searchLat={searchCenterLat!} searchLng={searchCenterLng!} radiusKm={searchRadiusKm!} />
: <RecenterOnChange lat={lat} lng={lng} />
}
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Search radius circle */}
{hasSearch && (
<Circle
center={[searchCenterLat!, searchCenterLng!]}
radius={searchRadiusKm! * 1000}
pathOptions={{ color: '#7c3aed', fillColor: '#7c3aed', fillOpacity: 0.07, weight: 2, dashArray: '6 4' }}
/>
)}
{/* Search center marker */}
{hasSearch && (
<Marker position={[searchCenterLat!, searchCenterLng!]} icon={searchIcon}>
<Popup>{searchLabel ?? `Suchradius: ${searchRadiusKm} km`}</Popup>
</Marker>
)}
{/* Property marker */}
<Marker position={[lat, lng]}>
{label && <Popup>{label}</Popup>}
</Marker>
</MapContainer>
{/* Legend overlay */}
{hasSearch && (
<Box sx={{
position: 'absolute', bottom: 8, left: 8, zIndex: 500,
bgcolor: 'rgba(255,255,255,0.92)', borderRadius: 1.5,
px: 1.25, py: 0.625, display: 'flex', flexDirection: 'column', gap: 0.4,
boxShadow: '0 1px 6px rgba(0,0,0,0.12)',
backdropFilter: 'blur(4px)',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#7c3aed', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#5b21b6', fontWeight: 700 }}>
{searchLabel ?? 'Suchzentrum'} · {searchRadiusKm} km Radius
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#1e3a5f', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#334155' }}>Objekt</Typography>
</Box>
</Box>
)}
</Box>
)
}
+1 -1
View File
@@ -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 }}>
+198
View File
@@ -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>
)
}
+122 -20
View File
@@ -1,4 +1,5 @@
import { Box, Divider, LinearProgress, TextField, Typography } from '@mui/material'
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
import { ExternalLink, FileText } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property'
import { PropertyMap } from '../shared'
import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
@@ -97,18 +98,75 @@ 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 20212026"
/>
</Box>
</Box>
) : (
<FieldGrid>
<Field label="Aktueller Mieter" value={p.currentTenant} />
<Field label="Mietlaufzeit" value={p.leaseTerm} />
<Field label="Mietbeginn" value={p.leaseStartDate ? new Date(p.leaseStartDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Mietende" value={p.leaseEndDate ? new Date(p.leaseEndDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Breakoutoption" value={p.breakoutOption} />
<Field label="Breakoutoption Zeitpunkt" value={p.breakoutOptionDate ? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Importiert aus" value={p.importedFrom} />
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
</FieldGrid>
<>
<FieldGrid>
<Field label="Aktueller Mieter" value={p.currentTenant} />
<Field label="Mietlaufzeit" value={p.leaseTerm} />
<Field label="Mietbeginn" value={p.leaseStartDate ? new Date(p.leaseStartDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Mietende" value={p.leaseEndDate ? new Date(p.leaseEndDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Breakoutoption" value={p.breakoutOption} />
<Field label="Breakoutoption Zeitpunkt" value={p.breakoutOptionDate ? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH') : undefined} />
<Field label="Importiert aus" value={p.importedFrom} />
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
</FieldGrid>
{/* Mietvertrag */}
<Box sx={{ mt: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: p.leaseContractUrl ? '#f8fafc' : '#fafafa' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: p.leaseContractUrl ? 0.75 : 0 }}>
<FileText size={13} color={p.leaseContractUrl ? '#1e3a5f' : '#94a3b8'} />
<Typography variant="caption" sx={{ fontWeight: 700, color: p.leaseContractUrl ? '#1e3a5f' : '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.4 }}>
Mietvertrag
</Typography>
</Box>
{p.leaseContractUrl ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500 }} noWrap>
{p.leaseContractName ?? 'Mietvertrag'}
</Typography>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={11} />}
href={p.leaseContractUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.25, px: 1, borderColor: '#cbd5e1', color: '#1e3a5f', whiteSpace: 'nowrap', flexShrink: 0 }}
>
Öffnen
</Button>
</Box>
) : (
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Noch kein Mietvertrag hinterlegt im Bearbeitungsmodus hinzufügen.
</Typography>
)}
</Box>
</>
)}
{/* Floor / unit structure */}
@@ -121,15 +179,59 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
{/* Object details */}
<SectionTitle title="Objekt & Lage" />
<FieldGrid>
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
</FieldGrid>
{editing ? (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
<TextField
select label="Ausbaustandard"
size="small" fullWidth
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
>
{[
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basisausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters zum Ausbau"
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Parkplätze"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0 } }}
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Deckenhöhe (m)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
/>
</Box>
) : (
<FieldGrid>
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
{p.hardFacts?.mieterausbaubeitragPerSqm && (
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
)}
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
</FieldGrid>
)}
{/* Map */}
{p.location.coordinates && (
+8 -6
View File
@@ -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}
/>
{/* Availability badge */}
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
<Chip
label={getAvailabilityLabel(p.availabilityStatus)}
size="small"
sx={{
bgcolor: availabilityBadgeColor(p.availabilityStatus),
color: 'white',
fontWeight: 700,
fontSize: 10,
height: 20,
}}
{/* ── 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' }}
/>
</Box>
) : (
<Box
sx={{
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>
{p.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, mb: 1.25 }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
{/* ── Card body ── */}
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
{/* Key specs */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
<Chip label={`${p.areaSqm}`} 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 }} />
{/* Title + location */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
{p.title}
</Typography>
<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 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,26 +70,37 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
</Alert>
) : (
SECTION_CONFIG.map(({ category, label, icon, color }) => {
const signals = report.signals.filter(s => s.category === category)
if (signals.length === 0) return null
return (
<Box key={category} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<Box sx={{ color }}>{icon}</Box>
<Typography variant="body2" sx={{ fontWeight: 700, color }}>
{label}
</Typography>
<Chip
label={signals.length}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#64748b' }}
/>
<>
{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 (
<Box key={category} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<Box sx={{ color }}>{icon}</Box>
<Typography variant="body2" sx={{ fontWeight: 700, color }}>
{label}
</Typography>
<Chip
label={signals.length}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#64748b' }}
/>
</Box>
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
</Box>
{signals.map(s => <SignalCard key={s.id} signal={s} />)}
</Box>
)
})
)
})}
</>
)}
{dialogOpen && (
+25 -2
View File
@@ -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>
+116 -35
View File
@@ -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,59 +57,83 @@ 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
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 2,
}}
>
{filtered.map(r => (
<ReminderCard key={r.id} reminder={r} />
))}
<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',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 2,
}}
>
{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>
)
}
+62 -100
View File
@@ -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,127 +16,89 @@ 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">
{/* Search */}
<TextField
size="small"
placeholder="Suchen…"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search size={16} color="#94a3b8" />
</InputAdornment>
),
},
}}
sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
{/* Search */}
<TextField
size="small"
placeholder="Suchen…"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search size={16} color="#94a3b8" />
</InputAdornment>
),
},
}}
sx={{ width: 200, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
{/* View mode */}
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Ansicht:</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, v) => v && setViewMode(v)}
size="small"
>
<ToggleButton value="list" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Liste</ToggleButton>
<ToggleButton value="card" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Karten</ToggleButton>
</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
{/* 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}
exclusive
onChange={(_, v) => v && setFilterType(v)}
size="small"
onChange={e => setFilterType(e.target.value as ReminderTypeType | 'ALL')}
sx={{ fontSize: '0.8125rem', minWidth: 150 }}
>
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
<MenuItem value="ALL" sx={{ fontSize: '0.8125rem' }}>Alle Typen</MenuItem>
{Object.values(ReminderType).map(t => (
<ToggleButton key={t} value={t} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
<MenuItem key={t} value={t} sx={{ fontSize: '0.8125rem' }}>
{TYPE_LABELS[t]}
</ToggleButton>
</MenuItem>
))}
</ToggleButtonGroup>
</Box>
</Select>
</FormControl>
</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)}
{/* Archive toggle */}
<FormControlLabel
control={
<Switch
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>
checked={showArchive}
onChange={e => setFilterStatus(e.target.checked ? 'ALL' : 'ACTIVE')}
/>
}
label={
<Typography variant="caption" color="text.secondary">
Abgeschlossene anzeigen
</Typography>
}
sx={{ m: 0 }}
/>
{/* 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>
{/* View mode */}
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Ansicht:</Typography>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, v) => v && setViewMode(v)}
size="small"
>
<ToggleButton value="list" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Liste</ToggleButton>
<ToggleButton value="card" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Karten</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
)
+70 -22
View File
@@ -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>
)
+13 -2
View File
@@ -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' },
+63 -3
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react'
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Tooltip, Typography } from '@mui/material'
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
import { useUpdateUnit } from '../../hooks/useProperties'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { floorLabel } from './PropertyDetailHelpers'
@@ -27,6 +28,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
const navigate = useNavigate()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
const updateUnit = useUpdateUnit(p.id)
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
@@ -114,9 +118,65 @@ export function UnitStructurePanel({ p }: { p: Property }) {
{u.available ? (
<>
<Chip label="Frei" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: DS_SURFACE.success.bg, color: '#166534', border: `1px solid ${DS_SURFACE.success.border}` }} />
{u.isFlexible && (
<Chip label="Teilfläche möglich" size="small" sx={{ height: 16, fontSize: '0.58rem', bgcolor: DS_SURFACE.blue.bg, color: '#1d4ed8', border: `1px solid ${DS_SURFACE.blue.border}` }} />
{/* Teilbar toggle */}
<Tooltip title={u.isFlexible ? 'Teilbar — Mindesteinheit bearbeiten' : 'Als teilbar markieren'}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Switch
size="small"
checked={u.isFlexible ?? false}
onChange={e => {
const flexible = e.target.checked
if (flexible) {
setEditingFlexUnit(u.id)
setFlexDraft(d => ({ ...d, [u.id]: u.minLettableSqm }))
} else {
updateUnit.mutate({ unitId: u.id, data: { isFlexible: false, minLettableSqm: undefined } })
setEditingFlexUnit(null)
}
}}
sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#1d4ed8' } }}
/>
<Typography sx={{ fontSize: '0.58rem', color: u.isFlexible ? '#1d4ed8' : DS_TEXT.disabled, fontWeight: u.isFlexible ? 700 : 400 }}>
Teilbar
</Typography>
</Box>
</Tooltip>
{/* Inline min-unit editor */}
{editingFlexUnit === u.id && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TextField
size="small"
type="number"
placeholder="Min m²"
value={flexDraft[u.id] ?? ''}
onChange={e => setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))}
sx={{ width: 72, '& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 } }}
slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
/>
<Button
size="small"
variant="contained"
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }}
onClick={() => {
updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } })
setEditingFlexUnit(null)
}}
>
</Button>
<Button
size="small"
variant="text"
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.5, minWidth: 0, color: DS_TEXT.muted }}
onClick={() => setEditingFlexUnit(null)}
>
</Button>
</Box>
)}
<Button
size="small"
variant="text"
+13
View File
@@ -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[]
}
+24
View File
@@ -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[]
}
+27
View File
@@ -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'>
+5
View File
@@ -92,6 +92,11 @@ export interface Need {
requireBarrierFree?: boolean
minCeilingHeightM?: number
minContractDurationMonths?: number
searchRadius?: number
isAnonymous?: boolean
requiresDivisibility?: boolean
minDivisibleUnit?: number
fitOutBudgetMaxPerSqm?: number
softFactors?: SoftFactorPreferences
weightingProfile: WeightingProfile
+5
View File
@@ -26,6 +26,11 @@ export interface ParsedNeedCriteria {
footfallNeed?: 'LOW' | 'MEDIUM' | 'HIGH'
companyName?: string
notes?: string
searchRadius?: number
isAnonymous?: boolean
requiresDivisibility?: boolean
minDivisibleUnit?: number
fitOutBudgetMaxPerSqm?: number
}
export interface FollowUpQuestion {
+5 -1
View File
@@ -3,6 +3,7 @@ import type {
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
} from './enums'
import { AvailabilityStatus as AS } from './enums'
import type { PropertyUnit } from './unit'
// Re-export unit types so all existing imports from 'property' continue to work
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
@@ -53,6 +54,7 @@ export interface DataQuality {
export interface PropertyHardFacts {
floor?: number
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mieterausbaubeitragPerSqm?: number
parking?: number
publicTransportScore?: number
usageType?: string
@@ -135,6 +137,8 @@ export interface Property {
description?: string
images?: string[]
floorPlanUrl?: string
leaseContractUrl?: string // link to the signed lease document
leaseContractName?: string // display label, e.g. "Mietvertrag 20212026"
propertyNumber?: string
units?: PropertyUnit[]
@@ -150,7 +154,7 @@ export interface Property {
importedAt?: string
lastUpdatedAt?: string
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number }
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
lastReviewedAt?: string
+3
View File
@@ -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)
})
+33
View File
@@ -12,6 +12,7 @@ import type {
} from '../../components/match-card/MatchCardViewModel'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import { formatUnitTitle, formatMultiUnitFloors } from '../../domain/unit'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -137,6 +138,38 @@ export function buildMatchCardViewModel(
unitId: match.unitId ?? unit?.id ?? signal?.unitId,
preMarketUnit: unit,
preMarketAllUnits: property?.units,
fitOutLabel: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!fitOut) return undefined
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const mab = property.hardFacts?.mieterausbaubeitragPerSqm
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
return LABELS[fitOut] ?? fitOut
})(),
fitOutViable: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!fitOut) return undefined
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
if (fitOut === 'BASIC' && mab >= 150) return true
if (fitOut === 'SHELL' && mab >= 350) return true
return undefined
})(),
fitOutInvestment: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
const area = property.areaSqm ?? 0
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
})(),
isDivisible: property?.units ? property.units.length > 1 : false,
minDivisibleUnitSqm: (() => {
if (!property?.units || property.units.length <= 1) return undefined
const flexible = property.units.filter(u => u.isFlexible && u.minLettableSqm)
if (flexible.length > 0) return Math.min(...flexible.map(u => u.minLettableSqm!))
return Math.min(...property.units.map(u => u.areaSqm))
})(),
scoreBreakdown: {
hardMatchScore: match.scoreBreakdown.hardMatchScore,
softFactorScore: match.scoreBreakdown.softFactorScore,
+66
View File
@@ -106,6 +106,22 @@ const RULES: KeywordRule[] = [
check: (_p) => null, // no structured field yet — always UNKNOWN
explanation: (_p, _ok) => 'Tageslicht / Fensterfront nicht strukturiert erfasst',
},
{
keywords: ['ausbaugrad', 'ausbaustandard', 'vollausbau', 'modern ausgebaut', 'plug and play', 'bezugsfertig', 'gehoben ausgebaut'],
check: (p) => {
const fitOut = p.hardFacts?.fitOut
if (!fitOut) return null
return fitOut === 'FULL' || fitOut === 'PREMIUM'
},
explanation: (p, ok) => {
const fitOut = p.hardFacts?.fitOut
if (!fitOut) return 'Ausbaustandard nicht dokumentiert'
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
return ok
? `${LABELS[fitOut] ?? fitOut} — moderner Ausbaustandard erfüllt`
: `${LABELS[fitOut] ?? fitOut} — nicht auf modernem Ausbauniveau`
},
},
]
// ── Number extraction helpers ──────────────────────────────────────────────────
@@ -115,10 +131,25 @@ function extractNumber(text: string): number | null {
return m ? parseInt(m[1], 10) : null
}
function extractMinuteLimit(text: string): number | null {
const m = text.match(/(?:unter|<|≤|max\.?)\s*(\d+)/) ?? text.match(/(\d+)\s*min/i)
return m ? parseInt(m[1], 10) : null
}
function isParkingKeyword(text: string): boolean {
return RULES[4].keywords.some(kw => text.toLowerCase().includes(kw))
}
function isOevKeyword(text: string): boolean {
return text.includes('öv-anbindung') || text.includes('öv anbindung') ||
text.includes('öv-anschluss') || text.includes('öffentlicher verkehr') ||
(text.includes('öv') && (text.includes('min') || text.includes('anbindung')))
}
function isMindestflaecheKeyword(text: string): boolean {
return text.includes('mindestfläche') || text.includes('mindestnutzfläche') || text.includes('mindest-fläche')
}
// ── Main scorer ───────────────────────────────────────────────────────────────
export function scoreMustHaves(
@@ -137,6 +168,41 @@ export function scoreMustHaves(
const results: MustHaveResult[] = criteria.map((raw) => {
const lower = raw.toLowerCase()
// Special case: ÖV-Anbindung with minute limit ("< 5 Min", "unter 10 Min")
if (isOevKeyword(lower)) {
const minutes = property.softFactors?.publicTransportMinutes
if (minutes === undefined) {
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'ÖV-Fahrzeit nicht dokumentiert' }
}
const limit = extractMinuteLimit(lower) ?? 10
const passed = minutes <= limit
return {
criterion: raw,
passed,
confidence: 'CERTAIN' as const,
explanation: passed
? `${minutes} Min. zu Fuss zum ÖV — Limit ${limit} Min. erfüllt`
: `${minutes} Min. zu Fuss — überschreitet ${limit}-Min.-Limit`,
}
}
// Special case: Mindestfläche with m² threshold ("Mindestfläche 600m²")
if (isMindestflaecheKeyword(lower)) {
const required = extractNumber(lower)
if (required === null) {
return { criterion: raw, passed: false, confidence: 'UNKNOWN' as const, explanation: 'Mindestfläche konnte nicht ausgelesen werden' }
}
const passed = property.areaSqm >= required
return {
criterion: raw,
passed,
confidence: 'CERTAIN' as const,
explanation: passed
? `${property.areaSqm.toLocaleString('de-CH')} m² deckt Mindestfläche ${required} m² ab`
: `${property.areaSqm.toLocaleString('de-CH')} m² liegt unter Mindestfläche ${required}`,
}
}
// Special case: parking with minimum count ("mind. 5 parkplätze")
if (isParkingKeyword(lower)) {
const required = extractNumber(lower)
+35 -9
View File
@@ -463,24 +463,50 @@ function scoreMinContractDuration(need: Need, property: Property): ScoreFactor |
}
}
const FIT_OUT_UPGRADE_COST: Record<string, number> = {
'SHELL_BASIC': 200,
'SHELL_FULL': 350,
'SHELL_PREMIUM': 450,
'BASIC_FULL': 150,
'BASIC_PREMIUM': 250,
'FULL_PREMIUM': 100,
}
function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
if (!need.requiredFitOut) return null
const propFitOut = property.hardFacts?.fitOut
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
if (!propFitOut) {
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
}
const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
if (propLevel >= reqLevel) {
return {
criterion: 'fitOut', weight: 0.04, score: 100, contribution: 4,
explanation: `${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung (${LABELS[need.requiredFitOut] ?? need.requiredFitOut})`,
}
}
// Check if combined budget (tenant + MAB) covers the upgrade gap
const upgradeKey = `${propFitOut}_${need.requiredFitOut}`
const upgradeCost = FIT_OUT_UPGRADE_COST[upgradeKey]
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
const tenantBudget = need.fitOutBudgetMaxPerSqm ?? 0
const totalBudget = mab + tenantBudget
if (upgradeCost && totalBudget >= upgradeCost) {
const mabNote = mab > 0 ? ` (inkl. CHF ${mab} MAB)` : ''
return {
criterion: 'fitOut', weight: 0.04, score: 80, contribution: 3.2,
explanation: `${LABELS[propFitOut]} — Ausbau auf ${LABELS[need.requiredFitOut]} mit CHF ${totalBudget}/m² Budget überbrückbar${mabNote}`,
}
}
const score = Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
const budgetNote = tenantBudget > 0 || mab > 0
? ` (Budget CHF ${totalBudget}/m² — Ausbau ~CHF ${upgradeCost ?? '?'}/m²)`
: ''
return {
criterion: 'fitOut',
weight: 0.04,
score,
contribution: score * 0.04,
explanation: propLevel >= reqLevel
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
: `${LABELS[propFitOut] ?? propFitOut}${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
criterion: 'fitOut', weight: 0.04, score, contribution: score * 0.04,
explanation: `${LABELS[propFitOut] ?? propFitOut}${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich${budgetNote}`,
}
}
+3
View File
@@ -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))
+21
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -45,6 +45,9 @@ export interface NewListingFormState {
fitOut: string
parking: string
ceilingHeight: string
mieterausbaubeitrag: string
isFlexible: boolean
minLettableSqm: string
// Contact
contactName: string
contactEmail: string
@@ -79,6 +82,9 @@ export interface NewListingFormHandlers {
setFitOut: (v: string) => void
setParking: (v: string) => void
setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
setIsFlexible: (v: boolean) => void
setMinLettableSqm: (v: string) => void
setContactName: (v: string) => void
setContactEmail: (v: string) => void
setContactPhone: (v: string) => void
@@ -113,6 +119,9 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [fitOut, setFitOut] = useState('')
const [parking, setParking] = useState('')
const [ceilingHeight, setCeilingHeight]= useState('')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState('')
const [isFlexible, setIsFlexible] = useState(false)
const [minLettableSqm, setMinLettableSqm] = useState('')
const [images, setImages] = useState<string[]>([])
const [imageInput, setImageInput] = useState('')
const [floorPlanUrl, setFloorPlanUrl] = useState('')
@@ -161,6 +170,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
}),
{
onSuccess: () => setCreated(true),
@@ -176,6 +186,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
setCreated(false); setError(null)
@@ -184,7 +195,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return {
assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight,
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created,
@@ -194,7 +205,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight,
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage,
+15
View File
@@ -1,5 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { propertyService } from '../services/propertyService'
import { unitService } from '../services/unitService'
import type { PropertyUnit } from '../domain/property'
import { matchService } from '../services/matchService'
import { futureSignalService } from '../services/futureSignalService'
import type { AssetType, ResultType } from '../domain/enums'
@@ -13,6 +15,7 @@ interface PropertyFilter {
minAreaSqm?: number
maxRentPerSqm?: number
organizationId?: string
sourceType?: string
}
export function useProperties(filter?: PropertyFilter) {
@@ -88,6 +91,18 @@ export function useCreateProperty() {
})
}
export function useUpdateUnit(propertyId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ unitId, data }: { unitId: string; data: Partial<PropertyUnit> }) =>
unitService.update(unitId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['properties'] })
queryClient.invalidateQueries({ queryKey: ['property', propertyId] })
},
})
}
export function useRemoveProperty() {
const queryClient = useQueryClient()
return useMutation({
+2 -2
View File
@@ -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 -2
View File
@@ -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) {
+8
View File
@@ -131,3 +131,11 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
LOW: 'Niedrig',
INCOMPLETE: 'Unvollständig',
}
// Swiss CRB/BKP fit-out investment benchmarks (CHF/m², tenant's cost)
export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number }> = {
SHELL: { min: 800, max: 1500 },
BASIC: { min: 400, max: 800 },
FULL: { min: 50, max: 200 },
PREMIUM: { min: 0, max: 50 },
}
+58
View File
@@ -0,0 +1,58 @@
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
export interface FitOutInvestment {
grossPerSqm: { min: number; max: number }
mabOffset: number
netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number }
isFullyCovered: boolean
shortLabel: string
detailLabel: string
}
function formatChfK(value: number): string {
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
return `CHF ${Math.round(value / 1000)}k`
}
export function calcFitOutInvestment(
fitOut: string,
areaSqm: number,
mabPerSqm: number,
tenantBudgetPerSqm: number,
): FitOutInvestment | null {
const benchmarks = FIT_OUT_COST_CHF_PER_SQM[fitOut]
if (!benchmarks) return null
const gross = benchmarks
const totalOffset = mabPerSqm + tenantBudgetPerSqm
const netMin = Math.max(0, gross.min - totalOffset)
const netMax = Math.max(0, gross.max - totalOffset)
const netTotal = {
min: Math.round(netMin * areaSqm),
max: Math.round(netMax * areaSqm),
}
const isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered
? 'Investition gedeckt'
: netTotal.min === netTotal.max
? `Est. ${formatChfK(netTotal.min)}`
: `Est. ${formatChfK(netTotal.min)}${formatChfK(netTotal.max)}`
const detailLabel = isFullyCovered
? 'Ausbaukosten durch MAB/Budget gedeckt'
: `CHF ${netMin.toLocaleString('de-CH')}${netMax.toLocaleString('de-CH')}/m² Nettoinvestition`
return {
grossPerSqm: gross,
mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax },
netTotal,
isFullyCovered,
shortLabel,
detailLabel,
}
}
+31
View File
@@ -145,3 +145,34 @@ export function getMarketRent(city: string, assetType: string): number | null {
if (assetType === 'RETAIL') return intel.medianRentRetail
return intel.medianRentOffice
}
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
const CITY_COORDS: Record<string, { lat: number; lng: number }> = {
'Zürich': { lat: 47.3769, lng: 8.5417 },
'Zürich-West': { lat: 47.3862, lng: 8.5195 },
'Zürich-Nord': { lat: 47.4108, lng: 8.5450 },
'Basel': { lat: 47.5596, lng: 7.5886 },
'Bern': { lat: 46.9480, lng: 7.4474 },
'Luzern': { lat: 47.0502, lng: 8.3093 },
'Genf': { lat: 46.2044, lng: 6.1432 },
'Lausanne': { lat: 46.5197, lng: 6.6323 },
'Zug': { lat: 47.1661, lng: 8.5158 },
'Winterthur': { lat: 47.5004, lng: 8.7256 },
'St. Gallen': { lat: 47.4245, lng: 9.3767 },
'Lugano': { lat: 46.0037, lng: 8.9511 },
'Baar': { lat: 47.1965, lng: 8.5286 },
'Muttenz': { lat: 47.5218, lng: 7.6468 },
'Pratteln': { lat: 47.5163, lng: 7.6926 },
'Reinach BL': { lat: 47.4948, lng: 7.5946 },
'Allschwil': { lat: 47.5529, lng: 7.5368 },
'Binningen': { lat: 47.5363, lng: 7.5687 },
}
export function lookupCityCoords(location: string): { lat: number; lng: number } | null {
if (CITY_COORDS[location]) return CITY_COORDS[location]
// Prefix fallback: "Zürich Kreis 5" → "Zürich"
const base = location.split(' ')[0]
const key = Object.keys(CITY_COORDS).find(k => k.startsWith(base) || base === k)
return key ? CITY_COORDS[key] : null
}
+11
View File
@@ -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',
}
+4
View File
@@ -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
View File
@@ -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: 280360 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 500700 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: 400500 m² Labor + 200250 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 600800 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 600800 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 20242025: +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 ~400500 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 (~350400 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: 400500 m² Büro + 150200 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\'5002\'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\'5002\'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\'5002\'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' },
],
},
]
+220
View File
@@ -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 6001000m²',
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 15004000m²',
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 300700m² 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 400750m²',
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 180320m² 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 150250m²',
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 350600m² 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',
+130
View File
@@ -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',
},
]
+317
View File
@@ -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 (300700m²)' },
{ 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 (300700m²)' },
{ 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 (400750m²)' },
{ 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 (180320m²)' },
{ 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 (350600m²)' },
{ 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',
},
]
+144
View File
@@ -30,6 +30,7 @@ export const mockNeeds: Need[] = [
prestige: 0.08, accessibility: 0.10, expansionPotential: 0.04, flexibility: 0.05,
visibility: 0.02, footfall: 0.01, talentAccess: 0.06, esg: 0.02, taxEnvironment: 0.00,
},
searchRadius: 30,
confidenceInCriteria: 0.88,
extractedFromText: 'Wir suchen moderne Büroflächen in Zürich-West, ca. 700900m², Budget max CHF 42/m², Bezug Herbst 2025.',
organizationId: 'org-wincasa',
@@ -61,6 +62,10 @@ export const mockNeeds: Need[] = [
prestige: 0.02, accessibility: 0.10, expansionPotential: 0.05, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.02, esg: 0.02, taxEnvironment: 0.03,
},
searchRadius: 50,
requiresDivisibility: true,
minDivisibleUnit: 500,
fitOutBudgetMaxPerSqm: 120,
confidenceInCriteria: 0.94,
organizationId: 'org-wincasa',
createdAt: '2025-03-20T14:00:00Z',
@@ -95,6 +100,9 @@ export const mockNeeds: Need[] = [
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
visibility: 0.02, footfall: 0.01, talentAccess: 0.08, esg: 0.05, taxEnvironment: 0.00,
},
searchRadius: 20,
isAnonymous: true,
fitOutBudgetMaxPerSqm: 250,
confidenceInCriteria: 0.91,
extractedFromText: 'Suche repräsentative Büroflächen im Raum Basel/Allschwil, 500800m², max. CHF 38/m², Bezug Q4 2025.',
organizationId: 'org-wincasa',
@@ -437,6 +445,142 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
{
id: 'need-012',
companyName: 'Gewerbe Solutions AG',
contactName: 'Andreas Weber',
assetType: AssetType.LIGHT_INDUSTRIAL,
requiredArea: { min: 400, max: 750 },
preferredLocations: ['Pratteln', 'Muttenz', 'Schweizerhalle', 'Liestal'],
excludedLocations: [],
budgetRange: { maxPerSqm: 168, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-04-01',
latestMoveIn: '2026-10-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Lkw-Zufahrt', 'Hallenhöhe min 5m', 'Drehstrom vorhanden'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.22, location: 0.20, budget: 0.18, timing: 0.12,
prestige: 0.02, accessibility: 0.10, expansionPotential: 0.06, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.01,
},
confidenceInCriteria: 0.90,
extractedFromText: 'Gewerbe-/Lagerfläche 450700m² 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 200300m² 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 160230m² 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 400550m² in Luzern, max. CHF 312/m²/J, Parkplätze nötig.',
organizationId: 'org-wincasa',
createdAt: '2026-04-10T14:00:00Z',
updatedAt: '2026-04-10T14:00:00Z',
},
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
{
id: 'need-uc-c',
+216 -19
View File
@@ -95,7 +95,7 @@ export const mockProperties: Property[] = [
parkingSpots: 30,
publicTransportMinutes: 12,
},
hardFacts: { floor: 0, parking: 30, loadingDocksCount: 2, ceilingHeightM: 7.5, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 30, loadingDocksCount: 2, ceilingHeightM: 7.5, isBarrierFree: true },
floorLevel: 0,
expansionPotentialSqm: 800,
contractDurationMonths: 36,
@@ -153,6 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8,
publicTransportMinutes: 5,
},
hardFacts: { fitOut: 'BASIC' },
floorLevel: 2,
expansionPotentialSqm: 200,
contractDurationMonths: 48,
@@ -212,6 +213,7 @@ export const mockProperties: Property[] = [
parkingSpots: 14,
publicTransportMinutes: 8,
},
hardFacts: { fitOut: 'FULL' },
floorLevel: 4,
contractDurationMonths: 48,
ancillaryCosts: 4.5,
@@ -268,7 +270,7 @@ export const mockProperties: Property[] = [
parkingSpots: 25,
publicTransportMinutes: 14,
},
hardFacts: { floor: 0, parking: 25, loadingDocksCount: 4, ceilingHeightM: 8, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 25, loadingDocksCount: 4, ceilingHeightM: 8, isBarrierFree: true },
floorLevel: 0,
expansionPotentialSqm: 600,
contractDurationMonths: 60,
@@ -380,6 +382,7 @@ export const mockProperties: Property[] = [
parkingSpots: 40,
publicTransportMinutes: 18,
},
hardFacts: { fitOut: 'SHELL' },
floorLevel: 0,
expansionPotentialSqm: 1200,
contractDurationMonths: 120,
@@ -437,6 +440,7 @@ export const mockProperties: Property[] = [
parkingSpots: 6,
publicTransportMinutes: 6,
},
hardFacts: { fitOut: 'FULL' },
floorLevel: 5,
contractDurationMonths: 36,
ancillaryCosts: 6.0,
@@ -495,6 +499,7 @@ export const mockProperties: Property[] = [
parkingSpots: 18,
publicTransportMinutes: 7,
},
hardFacts: { fitOut: 'BASIC' },
floorLevel: 1,
contractDurationMonths: 48,
ancillaryCosts: 5.0,
@@ -551,6 +556,7 @@ export const mockProperties: Property[] = [
parkingSpots: 45,
publicTransportMinutes: 16,
},
hardFacts: { fitOut: 'SHELL' },
floorLevel: 0,
expansionPotentialSqm: 1500,
contractDurationMonths: 60,
@@ -608,7 +614,7 @@ export const mockProperties: Property[] = [
prestige: 56,
accessibility: 88,
visibilityScore: 72,
passerbyFrequency: 'MEDIUM_HIGH',
passerbyFrequency: 'HIGH',
talentAccess: 65,
parkingSpots: 1,
publicTransportMinutes: 5,
@@ -830,7 +836,7 @@ export const mockProperties: Property[] = [
prestige: 60,
accessibility: 88,
visibilityScore: 75,
passerbyFrequency: 'MEDIUM_HIGH',
passerbyFrequency: 'HIGH',
talentAccess: 70,
parkingSpots: 2,
publicTransportMinutes: 5,
@@ -889,7 +895,7 @@ export const mockProperties: Property[] = [
parkingSpots: 4,
publicTransportMinutes: 6,
},
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 3.2 },
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 3.2, mieterausbaubeitragPerSqm: 200 },
floorLevel: 1,
expansionPotentialSqm: 90,
contractDurationMonths: 84,
@@ -1048,7 +1054,7 @@ export const mockProperties: Property[] = [
parkingSpots: 4,
publicTransportMinutes: 7,
},
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 2.7 },
hardFacts: { floor: 1, parking: 4, fitOut: 'BASIC', ceilingHeightM: 2.7, mieterausbaubeitragPerSqm: 150 },
floorLevel: 1,
expansionPotentialSqm: 80,
contractDurationMonths: 84,
@@ -1112,6 +1118,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-EXT-003',
description: 'Attraktive Ladenfläche an der Bahnhofstrasse Bern, direkt beim Hauptbahnhof. EG, sehr hohe Laufkundschaft, maximale Visibilität. Ideal für bekannte Handelsmarken oder Dienstleister mit direktem Kundenkontakt.',
hardFacts: { fitOut: 'FULL' },
units: [
{ id: 'unit-003-1', propertyId: 'prop-003', floorLevel: 0, areaSqm: 320, available: true, rentPricePerSqm: 1140 },
],
@@ -1149,6 +1156,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-EXT-004',
description: 'Grosszügige Gewerbefläche an der Europaallee, Zürich Kreis 4, direkt beim Hauptbahnhof. Modernes Gebäude mit flexibler Aufteilung. Hervorragende ÖV-Anbindung und erstklassige Lage. Geeignet als Büro, Showroom oder gemischte Nutzung.',
hardFacts: { fitOut: 'FULL' },
units: [
{ id: 'unit-004-1', propertyId: 'prop-004', floorLevel: 2, areaSqm: 1150, available: true, rentPricePerSqm: 624 },
],
@@ -1193,6 +1201,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'LU-MW-015',
description: 'Helle Bürofläche am Kasernenplatz Luzern, im Herzen der Innenstadt. Das 3. Obergeschoss bietet eine grosszügige, flexible Raumaufteilung mit natürlichem Licht von drei Seiten. ÖV-Verbindungen in unmittelbarer Nähe (Bahnhof Luzern, 5 Minuten zu Fuss). Ideal für professionelle Dienstleister.',
hardFacts: { fitOut: 'BASIC' },
units: [
{ id: 'unit-015-1', propertyId: 'prop-015', floorLevel: 3, areaSqm: 650, available: true, rentPricePerSqm: 456 },
],
@@ -1236,6 +1245,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BL-MW-016',
description: 'Grosszügige Lagerhalle an der Rheinfelderstrasse, direkt im Industriegebiet Muttenz. Ebenerdige Anlieferung mit breitem Tor, 35 Aussenparkplätze. Anbindung A2/A3 unter 5 Minuten. Ideal für Lagerung, Distribution und Leichtindustrie.',
hardFacts: { fitOut: 'SHELL' },
units: [
{ id: 'unit-016-1', propertyId: 'prop-016', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2200, available: true, rentPricePerSqm: 192 },
],
@@ -1280,6 +1290,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-EXT-017',
description: 'Erstklassige Ladenfläche an der Löwenstrasse im Zürcher Hauptbahnhof-Umfeld. EG, direkt an der Fussgängerzone mit sehr hoher Frequenz durch Pendler und Touristen. Schaufensterfront auf zwei Seiten.',
hardFacts: { fitOut: 'FULL' },
units: [
{ id: 'unit-017-1', propertyId: 'prop-017', floorLevel: 0, areaSqm: 350, available: true, rentPricePerSqm: 1140 },
],
@@ -1323,6 +1334,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-EXT-018',
description: 'Praktische Bürofläche im 2. Obergeschoss, Breitenrainstrasse Bern. Grosszügige, lichtdurchflutete Räume in ruhigem Quartier nahe dem Stadtzentrum. Bahn- und Busanbindung in 8 Minuten Fussweg. Ideal für Büros, Beratungsfirmen oder Praxen.',
hardFacts: { fitOut: 'BASIC' },
units: [
{ id: 'unit-018-1', propertyId: 'prop-018', floorLevel: 2, areaSqm: 780, available: true, rentPricePerSqm: 372 },
],
@@ -1361,6 +1373,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BS-MW-019',
description: 'Grosszügige Produktionshalle mit erhöhten Bodenlasten und Dreiphasenstrom im Industriequartier Kleinhüningen Basel. Direkte LKW-Zufahrt über die Voltastrasse. Ideal für Leichtindustrie, Montage oder Lagerhaltung.',
hardFacts: { fitOut: 'SHELL' },
units: [
{ id: 'unit-019-1', propertyId: 'prop-019', floorLevel: 0, unitLabel: 'Produktionshalle', areaSqm: 1900, available: true, rentPricePerSqm: 156 },
],
@@ -1404,6 +1417,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZG-MW-020',
description: 'Grosszügige Bürofläche im 4. Obergeschoss, Industriestrasse Zug. Ruhige Lage mit Panorama-Bergblick, direkter Autobahnanschluss A4. Ideal für Firmen, die von der Zuger Steuerpolitik profitieren möchten, ohne Premium-Innenstadtmieten zu zahlen.',
hardFacts: { fitOut: 'BASIC' },
units: [
{ id: 'unit-020-1', propertyId: 'prop-020', floorLevel: 4, areaSqm: 820, available: true, rentPricePerSqm: 528 },
],
@@ -1448,6 +1462,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'GE-MW-021',
description: 'Exklusive Verkaufsfläche im Erdgeschoss an der Rue du Rhône, Genf Zentrum. Eine der prestigeträchtigsten Einkaufsstrassen der Schweiz. Ideal für Luxusmarken, Juweliere oder hochwertige Dienstleister mit Anforderung an Prestige und Visibilität.',
hardFacts: { fitOut: 'PREMIUM' },
units: [
{ id: 'unit-021-1', propertyId: 'prop-021', floorLevel: 0, areaSqm: 250, available: true, rentPricePerSqm: 1344 },
],
@@ -1491,6 +1506,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'SG-EXT-022',
description: 'Helle Bürofläche an der Marktgasse im Zentrum St. Gallens. Das 2. Obergeschoss bietet eine zusammenhängende, gut aufteilbare Fläche in historischem Stadtquartier. St. Gallen HB in 6 Minuten zu Fuss. Ideal für Kanzleien, Dienstleister oder regionale Niederlassungen.',
hardFacts: { fitOut: 'BASIC' },
units: [
{ id: 'unit-022-1', propertyId: 'prop-022', floorLevel: 2, areaSqm: 700, available: true, rentPricePerSqm: 336 },
],
@@ -1694,6 +1710,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-EXT-031',
description: 'Repräsentative Bürofläche im 3. Obergeschoss im modernen Hardturm-Areal Zürich-West. Die zusammenhängende Fläche von 780 m² ist offen gestaltet und kann flexibel unterteilt werden. Grosse Fensterflächen, Klimaanlage und ein Untergeschoss-Parkhaus sind vorhanden. Das Gebäude befindet sich in zentraler Lage mit hervorragender Anbindung an den öffentlichen Verkehr (Tram 4/13, Bahnhof Hardbrücke, 4 Minuten zu Fuss). Übergabe ab Oktober 2025 möglich.',
hardFacts: { fitOut: 'FULL' },
units: [
{ id: 'unit-031-1', propertyId: 'prop-031', floorLevel: 3, areaSqm: 780, available: true, rentPricePerSqm: 420 },
],
@@ -1732,6 +1749,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'ZH-MW-032',
description: 'Stilvolles Büroloft im 5. Obergeschoss eines ehemaligen Industriegebäudes an der Pfingstweidstrasse, Kreis 5. Die offene Loftstruktur mit Sichtbetondecken und -wänden schafft ein inspirierendes Arbeitsumfeld. Raumhöhe ca. 3,5 m, Holzböden, individuelle Klimatisierung. Panoramablick über die Dächer Zürichs. Ideal für kreative Unternehmen und Tech-Firmen. Verfügbar ab November 2025.',
hardFacts: { fitOut: 'BASIC' },
units: [
{ id: 'unit-032-1', propertyId: 'prop-032', floorLevel: 5, areaSqm: 720, available: true, rentPricePerSqm: 480 },
],
@@ -1770,6 +1788,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-MW-033',
description: 'Attraktive Retailfläche an der Marktgasse, direkt in der Berner Fussgängerzone. EG-Fläche mit hoher Laufkundschaft durch Tourismus und Pendler. Tram und Bus direkt vor dem Haus. Ideal für Fashion, Lifestyle oder Gastronomie-Konzepte.',
hardFacts: { fitOut: 'FULL' },
units: [
{ id: 'unit-033-1', propertyId: 'prop-033', floorLevel: 0, areaSqm: 260, available: true, rentPricePerSqm: 1440 },
],
@@ -1808,6 +1827,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BE-EXT-034',
description: 'Ansprechende Ladenfläche im trendigen Quartier Lorraine, Bern. EG mit Schaufensterfront, gut positioniert für Kreativwirtschaft und gehobene Kundschaft. Busanbindung in 6 Minuten zum Bahnhof Bern.',
hardFacts: { fitOut: 'BASIC' },
units: [
{ id: 'unit-034-1', propertyId: 'prop-034', floorLevel: 0, areaSqm: 340, available: true, rentPricePerSqm: 1320 },
],
@@ -1872,6 +1892,7 @@ export const mockProperties: Property[] = [
mapImageUrl: 'https://images.unsplash.com/photo-1524661135-423995f22d0b?w=600&h=300&fit=crop',
propertyNumber: 'BS-EXT-036',
description: 'Freistehendes Lager-/Logistikgebäude direkt an der Klybeckstrasse, Basel Hafen. Ebenerdige Anlieferung, 38 Aussenparkplätze. Hervorragende Lage für Distribution in der Nordwestschweiz. Kran und Sprinkleranlage vorhanden.',
hardFacts: { fitOut: 'SHELL' },
units: [
{ id: 'unit-036-1', propertyId: 'prop-036', floorLevel: 0, unitLabel: 'Lagerhalle', areaSqm: 2600, available: true, rentPricePerSqm: 180 },
],
@@ -1909,6 +1930,7 @@ export const mockProperties: Property[] = [
parkingSpots: 0,
publicTransportMinutes: 3,
},
hardFacts: { fitOut: 'FULL' },
floorLevel: 0,
contractDurationMonths: 48,
ancillaryCosts: 4.0,
@@ -2017,7 +2039,7 @@ export const mockProperties: Property[] = [
prestige: 48,
accessibility: 78,
visibilityScore: 68,
passerbyFrequency: 'MEDIUM_HIGH',
passerbyFrequency: 'HIGH',
talentAccess: 60,
parkingSpots: 2,
publicTransportMinutes: 5,
@@ -2230,7 +2252,7 @@ export const mockProperties: Property[] = [
prestige: 58,
accessibility: 85,
visibilityScore: 70,
passerbyFrequency: 'MEDIUM_HIGH',
passerbyFrequency: 'HIGH',
talentAccess: 65,
parkingSpots: 2,
publicTransportMinutes: 5,
@@ -2373,7 +2395,7 @@ export const mockProperties: Property[] = [
prestige: 55,
accessibility: 82,
visibilityScore: 70,
passerbyFrequency: 'MEDIUM_HIGH',
passerbyFrequency: 'HIGH',
talentAccess: 65,
parkingSpots: 0,
publicTransportMinutes: 5,
@@ -2486,7 +2508,7 @@ export const mockProperties: Property[] = [
parkingSpots: 5,
publicTransportMinutes: 6,
},
hardFacts: { floor: 2, parking: 5, fitOut: 'BASIC', ceilingHeightM: 2.8 },
hardFacts: { floor: 2, parking: 5, fitOut: 'BASIC', ceilingHeightM: 2.8, mieterausbaubeitragPerSqm: 250 },
floorLevel: 2,
contractDurationMonths: 84,
ancillaryCosts: 3.8,
@@ -3453,7 +3475,7 @@ export const mockProperties: Property[] = [
prestige: 85,
accessibility: 95,
visibilityScore: 88,
passerbyFrequency: 'MEDIUM_HIGH',
passerbyFrequency: 'HIGH',
talentAccess: 76,
parkingSpots: 0,
publicTransportMinutes: 3,
@@ -3709,7 +3731,7 @@ export const mockProperties: Property[] = [
parkingSpots: 30,
publicTransportMinutes: 14,
},
hardFacts: { floor: 0, parking: 30, loadingDocksCount: 4, ceilingHeightM: 12.5, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 30, loadingDocksCount: 4, ceilingHeightM: 12.5, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 120,
ancillaryCosts: 2.5,
@@ -3761,7 +3783,7 @@ export const mockProperties: Property[] = [
parkingSpots: 35,
publicTransportMinutes: 12,
},
hardFacts: { floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 120,
ancillaryCosts: 2.5,
@@ -3813,7 +3835,7 @@ export const mockProperties: Property[] = [
parkingSpots: 40,
publicTransportMinutes: 16,
},
hardFacts: { floor: 0, parking: 40, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 40, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 120,
ancillaryCosts: 2.2,
@@ -3866,7 +3888,7 @@ export const mockProperties: Property[] = [
parkingSpots: 50,
publicTransportMinutes: 18,
},
hardFacts: { floor: 0, parking: 50, loadingDocksCount: 4, ceilingHeightM: 12.0, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 50, loadingDocksCount: 4, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 60,
ancillaryCosts: 2.0,
@@ -3913,7 +3935,7 @@ export const mockProperties: Property[] = [
parkingSpots: 38,
publicTransportMinutes: 14,
},
hardFacts: { floor: 0, parking: 38, loadingDocksCount: 2, ceilingHeightM: 11.5, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 38, loadingDocksCount: 2, ceilingHeightM: 11.5, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 60,
ancillaryCosts: 2.5,
@@ -3958,7 +3980,7 @@ export const mockProperties: Property[] = [
parkingSpots: 35,
publicTransportMinutes: 14,
},
hardFacts: { floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 35, loadingDocksCount: 3, ceilingHeightM: 12.0, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 120,
ancillaryCosts: 2.5,
@@ -4266,7 +4288,7 @@ export const mockProperties: Property[] = [
parkingSpots: 12,
publicTransportMinutes: 12,
},
hardFacts: { floor: 0, parking: 12, loadingDocksCount: 1, ceilingHeightM: 5.5, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 12, loadingDocksCount: 1, ceilingHeightM: 5.5, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 60,
ancillaryCosts: 2.8,
@@ -4318,7 +4340,7 @@ export const mockProperties: Property[] = [
parkingSpots: 18,
publicTransportMinutes: 15,
},
hardFacts: { floor: 0, parking: 18, loadingDocksCount: 2, ceilingHeightM: 6.0, isBarrierFree: true },
hardFacts: { fitOut: 'SHELL', floor: 0, parking: 18, loadingDocksCount: 2, ceilingHeightM: 6.0, isBarrierFree: true },
floorLevel: 0,
contractDurationMonths: 60,
ancillaryCosts: 2.5,
@@ -4487,8 +4509,183 @@ export const mockProperties: Property[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// ─────────────────────────────────────────────────────────────────────────────
// DIRECT LISTINGS (6) — created via Inserate-Formular, sourceType DIRECT
// ─────────────────────────────────────────────────────────────────────────────
{
id: 'prop-d01',
title: 'Bürofläche Bachmattstrasse 28',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3906, lng: 8.4886 } },
address: { street: 'Bachmattstrasse', houseNumber: '28', postalCode: '8048', city: 'Zürich', country: 'CH' },
areaSqm: 320,
rentPricePerSqm: 380,
availabilityDate: '2026-06-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.80,
dataQuality: { score: 0.78, missingCriticalFields: [], missingOptionalFields: ['softFactors', 'hardFacts'], lastVerifiedAt: '2026-03-10', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 60, accessibility: 75, publicTransportMinutes: 8, parkingSpots: 4 },
hardFacts: { floor: 3, fitOut: 'BASIC', parking: 4 },
floorLevel: 3,
ancillaryCosts: 4.2,
riskLevel: RiskLevel.LOW,
description: 'Helle Bürofläche im 3. OG an der Bachmattstrasse, Zürich-Altstetten. 320 m², Basisausbau, Parkplätze im Haus. 8 Minuten zur S-Bahn Altstetten.',
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-02-14T09:30:00Z',
updatedAt: '2026-02-14T09:30:00Z',
},
{
id: 'prop-d02',
title: 'Gewerbe- & Lagerfläche Pratteln',
assetType: AssetType.LIGHT_INDUSTRIAL,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Pratteln', district: 'Industriezone Nord', canton: 'BL', country: 'CH', coordinates: { lat: 47.5199, lng: 7.6951 } },
address: { street: 'Industriestrasse', houseNumber: '14', postalCode: '4133', city: 'Pratteln', country: 'CH' },
areaSqm: 580,
rentPricePerSqm: 145,
availabilityDate: '2026-05-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
sourceType: 'DIRECT',
confidenceScore: 0.85,
dataQuality: { score: 0.82, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-04-01', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 40, accessibility: 80, parkingSpots: 10 },
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 10, ceilingHeightM: 6.5, loadingDocksCount: 2 },
floorLevel: 0,
ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW,
description: 'Gewerbe- und Lagerhalle im Industriegebiet Pratteln. Erdgeschoss, 580 m², 6,5 m Hallenhöhe, 2 Ladelucken, 10 Aussenparkplätze. Sofort verfügbar.',
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-03-05T11:00:00Z',
updatedAt: '2026-03-05T11:00:00Z',
},
{
id: 'prop-d03',
title: 'Bürofläche Bollwerk 15, Bern',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9497, lng: 7.4391 } },
address: { street: 'Bollwerk', houseNumber: '15', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 240,
rentPricePerSqm: 295,
availabilityDate: '2026-07-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.78,
dataQuality: { score: 0.74, missingCriticalFields: [], missingOptionalFields: ['hardFacts'], lastVerifiedAt: '2026-04-20', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 72, accessibility: 92, publicTransportMinutes: 3, parkingSpots: 0 },
hardFacts: { floor: 4, fitOut: 'FULL' },
floorLevel: 4,
ancillaryCosts: 3.5,
riskLevel: RiskLevel.LOW,
description: 'Repräsentative Bürofläche im 4. OG am Bollwerk, Bern. 240 m², Vollausbau, Toplage direkt beim Bahnhof Bern (3 Min.). Kein Parkplatz.',
images: ['https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-04-02T14:15:00Z',
updatedAt: '2026-04-02T14:15:00Z',
},
{
id: 'prop-d04',
title: 'Verkaufsfläche Stadthausstrasse Winterthur',
assetType: AssetType.RETAIL,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'INACTIVE',
location: { city: 'Winterthur', district: 'Zentrum', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4997, lng: 8.7241 } },
address: { street: 'Stadthausstrasse', houseNumber: '6', postalCode: '8400', city: 'Winterthur', country: 'CH' },
areaSqm: 185,
rentPricePerSqm: 360,
availabilityDate: '2026-08-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.72,
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] },
softFactors: { prestige: 68, accessibility: 85, visibilityScore: 80, publicTransportMinutes: 5 },
hardFacts: { floor: 0, fitOut: 'BASIC', hasStorefront: true },
floorLevel: 0,
ancillaryCosts: 5.0,
riskLevel: RiskLevel.MEDIUM,
description: 'Ladenfläche an der Stadthausstrasse Winterthur, Erdgeschoss, Schaufensterfront. 185 m², Basisausbau. Zentrale Lage, hohe Passantenfrequenz. Derzeit deaktiviert — Verhandlung sistiert.',
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-01-20T10:00:00Z',
updatedAt: '2026-04-15T08:00:00Z',
},
{
id: 'prop-d05',
title: 'Logistikhalle Muttenz Rheinstrasse',
assetType: AssetType.LOGISTICS,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Muttenz', district: 'Frenkendorf Ost', canton: 'BL', country: 'CH', coordinates: { lat: 47.5150, lng: 7.6469 } },
address: { street: 'Rheinstrasse', houseNumber: '61', postalCode: '4132', city: 'Muttenz', country: 'CH' },
areaSqm: 1200,
rentPricePerSqm: 130,
availabilityDate: '2026-06-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.88,
dataQuality: { score: 0.85, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-05-01', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 35, accessibility: 88, parkingSpots: 20 },
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 20, ceilingHeightM: 9.0, loadingDocksCount: 4, powerSupplyKva: 400 },
floorLevel: 0,
ancillaryCosts: 2.2,
riskLevel: RiskLevel.LOW,
description: 'Moderne Logistikhalle in Muttenz, 1200 m², 9 m Hallenhöhe, 4 Ladelucken, Stromanschluss 400 kVA. Direkter Autobahnanschluss A2/A3, 20 Aussenparkplätze.',
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-04-28T16:00:00Z',
updatedAt: '2026-04-28T16:00:00Z',
},
{
id: 'prop-d06',
title: 'Bürofläche Zürichstrasse 45, Luzern',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'INACTIVE',
location: { city: 'Luzern', district: 'Littau', canton: 'LU', country: 'CH', coordinates: { lat: 47.0530, lng: 8.2592 } },
address: { street: 'Zürichstrasse', houseNumber: '45', postalCode: '6014', city: 'Luzern', country: 'CH' },
areaSqm: 450,
rentPricePerSqm: 260,
availabilityDate: '2026-09-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.70,
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] },
softFactors: { prestige: 55, accessibility: 70, publicTransportMinutes: 12, parkingSpots: 8 },
hardFacts: { floor: 1, fitOut: 'BASIC', parking: 8 },
floorLevel: 1,
ancillaryCosts: 3.8,
riskLevel: RiskLevel.MEDIUM,
description: 'Bürofläche Zürichstrasse 45, Luzern-Littau. 450 m², 1. OG, Basisausbau. 8 Tiefgaragenplätze. Deaktiviert — Preisüberarbeitung ausstehend.',
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2025-12-10T08:00:00Z',
updatedAt: '2026-03-20T12:00:00Z',
},
]
// Assign lease contract URLs to ~50 % of properties (every other one)
mockProperties.forEach((p, i) => {
if (i % 2 !== 0) return
const startYear = p.leaseStartDate ? new Date(p.leaseStartDate).getFullYear() : 2020 + (i % 5)
const endYear = p.leaseEndDate ? new Date(p.leaseEndDate).getFullYear() : startYear + 5
p.leaseContractUrl = `https://docs.wincasa.ch/mietvertraege/${p.id}.pdf`
p.leaseContractName = `Mietvertrag ${startYear}${endYear}`
})
// Assign images sequentially per pool so no two properties ever share the same image
// (until pool wraps, which happens after ~12 properties per pool type)
const _poolCounters: Record<string, number> = {}
+40
View File
@@ -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',
+4 -1
View File
@@ -38,6 +38,7 @@ export default function AISearch() {
const [weights, setWeights] = useState<Record<WeightingKey, number>>(defaultWeights)
const [weightingKey, setWeightingKey] = useState(0)
const [needTitle, setNeedTitle] = useState('')
const [isAnonymous, setIsAnonymous] = useState(false)
const [error, setError] = useState<string | null>(null)
const isManualTextRef = useRef(false)
@@ -165,7 +166,7 @@ export default function AISearch() {
setStep(NeedBuilderStep.SAVING)
const entries = Object.entries(parseResult.confidenceByField)
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE')
const input = buildNeedInput({ ...editedCriteria, isAnonymous: isAnonymous || undefined }, weights, needTitle, conf, 'ACTIVE')
createNeedMutation.mutate(input, {
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ['matches'] })
@@ -253,7 +254,9 @@ export default function AISearch() {
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
+2 -3
View File
@@ -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' }}>
-1
View File
@@ -41,7 +41,6 @@ export default function Compare() {
overallWinnerIdx,
bestScoreIdx,
worstConfIdx,
dqScores,
worstDQIdx,
missingCriticalCounts,
maxMissingCritical,
+40 -62
View File
@@ -1,31 +1,19 @@
import { useState } from 'react'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
import { AlertTriangle, ArrowLeft } from 'lucide-react'
import { useNavigate, useParams } from 'react-router'
import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { PropertyMap } from '../../components/shared'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import {
LocationIntelligencePanel,
ExecutiveSummaryPanel,
NeedAlignmentPanel,
ScoreBreakdownPanel,
TradeoffPanel,
RiskPanel,
MissingInformationPanel,
FutureAvailabilityContextPanel,
NextActionsPanel,
} from '../../components/match-detail'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections'
import { MatchDetailScoreBreakdown } from '../../components/match-detail/MatchDetailScoreBreakdown'
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -49,7 +37,6 @@ export default function MatchDetail() {
const { addToCompare } = useCompareStore()
const { openSavedDialog } = usePipelineStore()
const { openInquiryDialog } = useInquiryStore()
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
@@ -85,13 +72,16 @@ export default function MatchDetail() {
const cityIntel = city ? getCityIntelligence(city) : null
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
const searchLabel = need?.preferredLocations?.[0]
const topTradeoff = match.tradeoffs?.[0] ?? match.tradeOffs?.[0]
const summary = match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten.`
const handleCompare = () => {
if (match && !isFuture && property) {
addToCompare({
resultType: property.resultType ?? 'VERIFIED_PORTFOLIO',
resultType: (property.resultType ?? 'VERIFIED_PORTFOLIO') as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
})
} else if (match && isFuture && signal) {
@@ -124,6 +114,15 @@ export default function MatchDetail() {
{ label: 'Miete/m²/Jahr', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}` : '' },
{ label: 'Verfügbar ab', value: property?.availabilityDate ?? '' },
{ label: 'Nutzungsart', value: property?.assetType ?? '' },
...(property?.hardFacts?.fitOut ? [{
label: 'Ausbaustandard',
value: (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return mab ? `${base} + CHF ${mab} MAB` : base
})(),
}] : []),
]
return (
@@ -165,6 +164,10 @@ export default function MatchDetail() {
keyFacts={keyFacts}
onCompare={handleCompare}
onShortlist={handleShortlist}
searchCenterLat={searchCenter?.lat}
searchCenterLng={searchCenter?.lng}
searchRadiusKm={need?.searchRadius}
searchLabel={searchLabel}
/>
{/* ── Main content ── */}
@@ -210,51 +213,26 @@ export default function MatchDetail() {
})}
/>
{/* ── Full analysis toggle ── */}
<Button
variant="outlined"
onClick={() => setShowFullAnalysis(v => !v)}
fullWidth
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
sx={{
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
textTransform: 'none', fontWeight: 500,
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
}}
>
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
</Button>
{/* ── Full analysis (hidden by default) ── */}
{showFullAnalysis && (
<>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
<LocationIntelligencePanel property={property} />
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
/>
</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} />
</>
{/* ── Section C: Reale Jahresbelastung (alle fitOut-Werte) ── */}
{!isFuture && property?.hardFacts?.fitOut && (
<FitOutCostPanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
/>
)}
{/* ── Full analysis toggle + expanded panels ── */}
<MatchDetailScoreBreakdown
match={match}
property={property}
need={need}
signal={signal}
isFuture={isFuture}
taxCalculatorUrl={taxCalculatorUrl}
/>
</Box>
</Box>
)
+33 -1
View File
@@ -1,5 +1,5 @@
import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Typography } from '@mui/material'
import { Box, Button, Card, Chip, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
@@ -161,6 +161,29 @@ export default function Results() {
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
</Typography>
)}
{activeNeed.searchRadius && (
<Typography variant="caption" color="text.secondary">
<strong>Radius:</strong> {activeNeed.searchRadius} km
</Typography>
)}
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
<Typography variant="caption" color="text.secondary">
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
</Typography>
)}
{activeNeed.requiredFitOut && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaustandard:</strong> min. {
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
}
</Typography>
)}
{activeNeed.fitOutBudgetMaxPerSqm && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
</Typography>
)}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
@@ -168,6 +191,14 @@ export default function Results() {
)}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{activeNeed.isAnonymous && (
<Chip
label="Anonyme Suche"
size="small"
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
/>
)}
<Button
size="small"
variant="text"
@@ -176,6 +207,7 @@ export default function Results() {
>
Suche ändern
</Button>
</Box>
</Box>
</Card>
)}
+364 -182
View File
@@ -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>
{/* 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>
{/* 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 (6079%)', 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 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}%
</Typography>
{/* 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 (6079%)', 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>
))}
</Box>
</Card>
</SectionContainer>
<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>
{/* 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 (6079%)</MenuItem>
<MenuItem value="LOW">Niedrig ({'<'}60%)</MenuItem>
<MenuItem value="INCOMPLETE">Pflichtfelder fehlen</MenuItem>
</Select>
{/* Object list */}
<Box>
<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 {properties.length} Objekten
{filtered.length} von {total} Objekten · sortiert nach Handlungsbedarf
</Typography>
{filter !== 'ALL' && (
<Chip
label={`Filter: ${filter}`}
size="small"
onDelete={() => setFilter('ALL')}
sx={{ height: 22, fontSize: '0.68rem' }}
/>
)}
</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
{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>
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 ? (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: topAction.priority === 'HIGH' ? '#c0392b' : '#d97706' }}>
{topAction.label}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
{topAction.detail}
</Typography>
</Box>
) : (
<Typography variant="caption" color="text.secondary"></Typography>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
</SectionContainer>
{filtered.map(p => (
<PropertyRow key={p.id} property={p} onEdit={setDetailId} />
))}
</Card>
) : (
<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
-296
View File
@@ -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>
)
}
+339 -91
View File
@@ -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
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' }}
/>
</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>
)
})}
{listings.map((p, i) => (
<ListingRow
key={p.id}
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>
)}
{/* 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>
)
}
+3
View File
@@ -83,6 +83,9 @@ export default function NewListing() {
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
isFlexible={form.isFlexible} onIsFlexibleChange={form.setIsFlexible}
minLettableSqm={form.minLettableSqm} onMinLettableSqmChange={form.setMinLettableSqm}
/>
<ImageUrlSection
+87 -46
View File
@@ -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() {
}
/>
{!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'),
},
]}
/>
)}
{/* 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>
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
<Collapse in={headerOpen}>
{!isLoading && properties.length > 0 && (
<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} />
))}
+1 -1
View File
@@ -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 }}>
+15
View File
@@ -19,11 +19,15 @@ export function buildCreatePropertyInput(fields: {
ceilingHeight: string
images: string[]
floorPlanUrl: string
mieterausbaubeitrag?: string
isFlexible?: boolean
minLettableSqm?: string
}): CreatePropertyInput {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields
const sf = {
@@ -41,6 +45,7 @@ export function buildCreatePropertyInput(fields: {
const hf = {
floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
@@ -76,5 +81,15 @@ export function buildCreatePropertyInput(fields: {
warnings: [],
},
status: 'ACTIVE',
units: isFlexible ? [{
id: `unit-listing-${Date.now()}`,
propertyId: '',
floorLevel: hf.floor ?? 0,
areaSqm,
available: true,
rentPricePerSqm: rentPerSqm,
isFlexible: true,
minLettableSqm: minLettableSqm ? parseInt(minLettableSqm) : undefined,
}] : undefined,
}
}
+8
View File
@@ -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 () => {
+1 -1
View File
@@ -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 () => {
+9 -3
View File
@@ -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)
+23 -1
View File
@@ -7,7 +7,7 @@ import type { UnifiedMatchResult } from '../../domain/unifiedResult'
export interface AIProvenance {
/** Which AI provider produced this response */
provider: 'openrouter' | 'mock'
provider: 'openrouter' | 'mock' | 'backend'
/** Exact model ID (e.g. 'anthropic/claude-3-5-haiku') or 'mock' */
model: string
/** ISO-8601 timestamp of generation */
@@ -157,6 +157,25 @@ export interface DataQualityInput {
warnings: string[]
}
// ── Fit-out advice ────────────────────────────────────────────────────────────
export interface FitOutAdviceInput {
fitOut: string
areaSqm: number
mabPerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
monthlyRentPerSqm: number
}
export interface FitOutAdvice {
recommendation: 'MIETERAUSBAU' | 'BKZ' | 'MAB_AMORTISATION'
headline: string
explanation: string
negotiationTip: string
estimatedNetInvestment: string
}
// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
@@ -198,6 +217,9 @@ export interface IAIService {
// Offer email (supply side)
generateOfferEmail(payload: OfferEmailPayload): Promise<AIResponse<{ subject: string; body: string }>>
// Fit-out investment advice (demand side)
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
// Legacy methods
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>

Some files were not shown because too many files have changed in this diff Show More